From 5c97555f1a702537bf23f8852f43ced017e6ede8 Mon Sep 17 00:00:00 2001 From: justin Date: Wed, 15 Apr 2026 20:26:54 -0600 Subject: [PATCH 01/23] Add seed and validate design spec Design doc for the Python orchestration script that seeds all pgFirstAid health checks and validates each one fires in a throwaway test database. --- .../2026-04-15-seed-and-validate-design.md | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-15-seed-and-validate-design.md 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 From b0844fb533d9911a67ce4e8c78d6359eac9c563a Mon Sep 17 00:00:00 2001 From: justin Date: Wed, 15 Apr 2026 21:18:58 -0600 Subject: [PATCH 02/23] feat(seed): rewrite 01_seed_static_checks with full structural seeds --- .../01_seed_static_checks.sql | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 testing/healthcheck_seed/01_seed_static_checks.sql 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..888c3e1 --- /dev/null +++ b/testing/healthcheck_seed/01_seed_static_checks.sql @@ -0,0 +1,281 @@ +-- pgFirstAid seed: structural and static checks +-- Idempotent. Run against the pgfirstaid_test database. +-- Requires the pgfirstaid_seed schema to not exist, or be dropped first. + +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 more than 200 columns +-- ============================================================ +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. +-- ============================================================ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'pgfirstaid_seed_role' + ) THEN + CREATE ROLE pgfirstaid_seed_role LOGIN PASSWORD 'seed_only'; + END IF; +END $$; + +-- ============================================================ +-- 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. +-- 200000 rows * ~70 bytes/row ≈ 14MB index. +-- Scanned exactly 5 times (1 < 5 < 100). +-- ============================================================ +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, 200000) g; +CREATE INDEX pgfirstaid_seed_low_usage_idx + ON pgfirstaid_seed.low_usage_idx_table (search_key); +ANALYZE pgfirstaid_seed.low_usage_idx_table; + +DO $$ +DECLARE + dummy bigint; +BEGIN + FOR i IN 1..5 LOOP + SELECT id INTO dummy + FROM pgfirstaid_seed.low_usage_idx_table + WHERE search_key = md5(i::text) + LIMIT 1; + END LOOP; +END $$; + +-- ============================================================ +-- 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'); From 637de4f148955dc45399a5969934588e6b5729ec Mon Sep 17 00:00:00 2001 From: justin Date: Wed, 15 Apr 2026 21:20:23 -0600 Subject: [PATCH 03/23] fix(seed): drop pgfirstaid_seed_role before create for clean re-runs --- testing/healthcheck_seed/01_seed_static_checks.sql | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/testing/healthcheck_seed/01_seed_static_checks.sql b/testing/healthcheck_seed/01_seed_static_checks.sql index 888c3e1..8d30530 100644 --- a/testing/healthcheck_seed/01_seed_static_checks.sql +++ b/testing/healthcheck_seed/01_seed_static_checks.sql @@ -222,15 +222,11 @@ CREATE TABLE pgfirstaid_seed.inactive_table ( -- ============================================================ -- 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). -- ============================================================ -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_roles WHERE rolname = 'pgfirstaid_seed_role' - ) THEN - CREATE ROLE pgfirstaid_seed_role LOGIN PASSWORD 'seed_only'; - END IF; -END $$; +DROP ROLE IF EXISTS pgfirstaid_seed_role; +CREATE ROLE pgfirstaid_seed_role LOGIN PASSWORD 'seed_only'; -- ============================================================ -- LOW: Empty Table From 71b7f5c75cfc78fed07eefef00f45d117e52104c Mon Sep 17 00:00:00 2001 From: justin Date: Wed, 15 Apr 2026 21:21:51 -0600 Subject: [PATCH 04/23] fix(seed): address code review feedback on 01_seed_static_checks --- testing/healthcheck_seed/01_seed_static_checks.sql | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/testing/healthcheck_seed/01_seed_static_checks.sql b/testing/healthcheck_seed/01_seed_static_checks.sql index 8d30530..6157c88 100644 --- a/testing/healthcheck_seed/01_seed_static_checks.sql +++ b/testing/healthcheck_seed/01_seed_static_checks.sql @@ -1,6 +1,6 @@ -- pgFirstAid seed: structural and static checks -- Idempotent. Run against the pgfirstaid_test database. --- Requires the pgfirstaid_seed schema to not exist, or be dropped first. +-- Drops and recreates the pgfirstaid_seed schema on each run. DROP SCHEMA IF EXISTS pgfirstaid_seed CASCADE; CREATE SCHEMA pgfirstaid_seed; @@ -45,7 +45,7 @@ CREATE INDEX pgfirstaid_seed_dup_idx_b ON pgfirstaid_seed.dup_idx_table (val); ANALYZE pgfirstaid_seed.dup_idx_table; -- ============================================================ --- HIGH: Table with more than 200 columns +-- HIGH: Table with 201 columns (threshold is >200) -- ============================================================ DO $$ DECLARE @@ -226,7 +226,8 @@ CREATE TABLE pgfirstaid_seed.inactive_table ( -- not dropped by DROP SCHEMA CASCADE). -- ============================================================ DROP ROLE IF EXISTS pgfirstaid_seed_role; -CREATE ROLE pgfirstaid_seed_role LOGIN PASSWORD 'seed_only'; +-- PASSWORD NULL: the check fires on schema structure, not authentication. +CREATE ROLE pgfirstaid_seed_role LOGIN PASSWORD NULL; -- ============================================================ -- LOW: Empty Table @@ -241,7 +242,7 @@ ANALYZE pgfirstaid_seed.empty_table; -- ============================================================ -- LOW: Index With Very Low Usage -- idx_scan > 0 AND idx_scan < 100 AND pg_relation_size > 1MB. --- 200000 rows * ~70 bytes/row ≈ 14MB index. +-- 25000 rows * ~70 bytes/row ≈ 1.75MB index, safely above 1MB threshold. -- Scanned exactly 5 times (1 < 5 < 100). -- ============================================================ CREATE TABLE pgfirstaid_seed.low_usage_idx_table ( @@ -250,7 +251,7 @@ CREATE TABLE pgfirstaid_seed.low_usage_idx_table ( ); INSERT INTO pgfirstaid_seed.low_usage_idx_table SELECT g, md5(g::text) -FROM generate_series(1, 200000) g; +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; From f132157efb019a4fc3132c095278d0ab94f5ecca Mon Sep 17 00:00:00 2001 From: justin Date: Wed, 15 Apr 2026 21:29:40 -0600 Subject: [PATCH 05/23] initial commit for testing harness --- pyproject.toml | 11 ++ .../02_seed_pg_stat_statements.sql | 112 ++++++++++++++++++ .../healthcheck_seed/03_session_blocker.sql | 23 ++++ .../healthcheck_seed/04_session_blocked.sql | 0 .../05_session_idle_in_transaction.sql | 1 + .../06_session_long_running_query.sql | 6 + .../07_pgbench_active_query.sql | 7 ++ .../99_validate_seed_results.sql | 17 +++ testing/healthcheck_seed/README.md | 42 +++++++ 9 files changed, 219 insertions(+) create mode 100644 pyproject.toml create mode 100644 testing/healthcheck_seed/02_seed_pg_stat_statements.sql create mode 100644 testing/healthcheck_seed/03_session_blocker.sql create mode 100644 testing/healthcheck_seed/04_session_blocked.sql create mode 100644 testing/healthcheck_seed/05_session_idle_in_transaction.sql create mode 100644 testing/healthcheck_seed/06_session_long_running_query.sql create mode 100644 testing/healthcheck_seed/07_pgbench_active_query.sql create mode 100644 testing/healthcheck_seed/99_validate_seed_results.sql create mode 100644 testing/healthcheck_seed/README.md diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..123fa50 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "pgfirstaid-testing" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "psycopg[binary]>=3.1", + "pytest>=8.0", +] + +[tool.pytest.ini_options] +testpaths = ["testing"] 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..e69de29 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..81cbff1 --- /dev/null +++ b/testing/healthcheck_seed/05_session_idle_in_transaction.sql @@ -0,0 +1 @@ +SELECT pg_sleep(360); 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..7ec991a --- /dev/null +++ b/testing/healthcheck_seed/07_pgbench_active_query.sql @@ -0,0 +1,7 @@ +-- 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 + + + + 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. From 350bcf5da1ed27f456f9fc72fc4c7f7f7f6acf6a Mon Sep 17 00:00:00 2001 From: randoneering Date: Thu, 16 Apr 2026 03:54:50 +0000 Subject: [PATCH 06/23] feat(seed): scaffold seed_and_validate.py with CLI and connection helpers --- testing/seed_and_validate.py | 62 +++++++++++++++++++++++++++++++ testing/test_seed_and_validate.py | 51 +++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 testing/seed_and_validate.py create mode 100644 testing/test_seed_and_validate.py diff --git a/testing/seed_and_validate.py b/testing/seed_and_validate.py new file mode 100644 index 0000000..b0a126d --- /dev/null +++ b/testing/seed_and_validate.py @@ -0,0 +1,62 @@ +#!/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 "psycopg[binary]" +""" + +import argparse +import os +import subprocess +import sys +import threading +import time +from pathlib import Path + +import psycopg + +SEED_DIR = Path(__file__).parent / "healthcheck_seed" +PG_FIRSTAID_SQL = Path(__file__).parent.parent / "pgFirstAid.sql" +TEST_DB = "pgfirstaid_test" + + +def get_conn_params(args: argparse.Namespace) -> dict: + """Build psycopg 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", ""), + "dbname": "postgres", + } + + +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)") + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + params = get_conn_params(args) + print(f"Connecting to {params['user']}@{params['host']}:{params['port']}") diff --git a/testing/test_seed_and_validate.py b/testing/test_seed_and_validate.py new file mode 100644 index 0000000..005b8e8 --- /dev/null +++ b/testing/test_seed_and_validate.py @@ -0,0 +1,51 @@ +import argparse +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from seed_and_validate import get_conn_params + + +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") + params = get_conn_params(_args()) + assert params["host"] == "db.example.com" + assert params["port"] == 5433 + assert params["user"] == "admin" + assert params["password"] == "secret" + + +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 From 168a0a616360039d3bc0d8fe57354e42c513c85e Mon Sep 17 00:00:00 2001 From: randoneering Date: Thu, 16 Apr 2026 03:55:23 +0000 Subject: [PATCH 07/23] feat(seed): add threshold patching for test-sized thresholds --- testing/seed_and_validate.py | 22 ++++++++++++++++++++++ testing/test_seed_and_validate.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/testing/seed_and_validate.py b/testing/seed_and_validate.py index b0a126d..ed23599 100644 --- a/testing/seed_and_validate.py +++ b/testing/seed_and_validate.py @@ -17,6 +17,7 @@ import argparse import os +import re import subprocess import sys import threading @@ -29,6 +30,27 @@ PG_FIRSTAID_SQL = Path(__file__).parent.parent / "pgFirstAid.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 psycopg connection kwargs from CLI args and env vars. diff --git a/testing/test_seed_and_validate.py b/testing/test_seed_and_validate.py index 005b8e8..9488362 100644 --- a/testing/test_seed_and_validate.py +++ b/testing/test_seed_and_validate.py @@ -4,7 +4,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) -from seed_and_validate import get_conn_params +from seed_and_validate import get_conn_params, patch_thresholds, PG_FIRSTAID_SQL def _args(**kwargs): @@ -49,3 +49,31 @@ def test_get_conn_params_cli_overrides_env(monkeypatch): params = get_conn_params(_args(host="cli-host", port="5432")) assert params["host"] == "cli-host" assert params["port"] == 5432 + + +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 From c8efbb816b452f29fbca07c65025deaf156add09 Mon Sep 17 00:00:00 2001 From: randoneering Date: Thu, 16 Apr 2026 04:18:12 +0000 Subject: [PATCH 08/23] feat(seed): add database lifecycle functions (create, drop, install) --- testing/seed_and_validate.py | 40 ++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/testing/seed_and_validate.py b/testing/seed_and_validate.py index ed23599..267975e 100644 --- a/testing/seed_and_validate.py +++ b/testing/seed_and_validate.py @@ -67,6 +67,46 @@ def get_conn_params(args: argparse.Namespace) -> dict: } +def connect_admin(params: dict) -> psycopg.Connection: + """Connect to the maintenance database with autocommit (for CREATE/DROP DATABASE).""" + return psycopg.connect(**params, autocommit=True) + + +def connect_test(params: dict) -> psycopg.Connection: + """Connect to the test database with autocommit for DDL.""" + test_params = {**params, "dbname": TEST_DB} + return psycopg.connect(**test_params, autocommit=True) + + +def create_test_db(admin_conn: psycopg.Connection) -> None: + """Drop and recreate the test database from scratch.""" + # Terminate any existing connections to the test db before dropping. + admin_conn.execute( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " + "WHERE datname = %s AND pid <> pg_backend_pid()", + (TEST_DB,), + ) + admin_conn.execute(f"DROP DATABASE IF EXISTS {TEST_DB}") + admin_conn.execute(f"CREATE DATABASE {TEST_DB}") + + +def drop_test_db(admin_conn: psycopg.Connection) -> None: + """Terminate all connections to the test database and drop it.""" + admin_conn.execute( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " + "WHERE datname = %s AND pid <> pg_backend_pid()", + (TEST_DB,), + ) + admin_conn.execute(f"DROP DATABASE IF EXISTS {TEST_DB}") + + +def install_function(test_conn: psycopg.Connection) -> None: + """Read pgFirstAid.sql, patch thresholds, and install into test DB.""" + sql = PG_FIRSTAID_SQL.read_text() + patched = patch_thresholds(sql) + test_conn.execute(patched) + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Seed and validate all pgFirstAid health checks" From da1700778c159bb6e9bdcfd43529b18a50dfeab7 Mon Sep 17 00:00:00 2001 From: randoneering Date: Thu, 16 Apr 2026 04:18:33 +0000 Subject: [PATCH 09/23] feat(seed): add seed file runners and replication slot guard --- testing/seed_and_validate.py | 78 ++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/testing/seed_and_validate.py b/testing/seed_and_validate.py index 267975e..ee5d235 100644 --- a/testing/seed_and_validate.py +++ b/testing/seed_and_validate.py @@ -107,6 +107,84 @@ def install_function(test_conn: psycopg.Connection) -> None: test_conn.execute(patched) +def run_sql_file(test_conn: psycopg.Connection, path: Path) -> None: + """Execute a plain SQL file against the test connection.""" + test_conn.execute(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", + ] + 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 + return True + + +def try_create_replication_slot(test_conn: psycopg.Connection) -> 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: + test_conn.execute( + "SELECT pg_create_logical_replication_slot(" + " 'pgfirstaid_test_slot', 'test_decoding')" + ) + return True + except psycopg.errors.ObjectNotInPrerequisiteState: + print(" SKIP: wal_level != logical — Inactive Replication Slots check not seeded") + return False + except psycopg.errors.InsufficientPrivilege: + print(" SKIP: insufficient privilege — Inactive Replication Slots check not seeded") + return False + + +def drop_replication_slot(test_conn: psycopg.Connection) -> None: + """Drop the test replication slot if it exists.""" + try: + test_conn.execute( + "SELECT pg_drop_replication_slot('pgfirstaid_test_slot')" + ) + except Exception: + pass + + +def verify_seed_sizes(test_conn: psycopg.Connection) -> None: + """Warn if size-seeded tables are outside expected ranges after patching.""" + row = test_conn.execute(""" + SELECT + pg_relation_size('pgfirstaid_seed.large_table') AS large_bytes, + pg_relation_size('pgfirstaid_seed.medium_table') AS medium_bytes + """).fetchone() + 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 parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Seed and validate all pgFirstAid health checks" From 996ce49ebdd589fda53e0c7391d1ef2555cfd2ed Mon Sep 17 00:00:00 2001 From: randoneering Date: Thu, 16 Apr 2026 04:19:04 +0000 Subject: [PATCH 10/23] feat(seed): add live session thread functions and wait helpers --- testing/seed_and_validate.py | 169 +++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/testing/seed_and_validate.py b/testing/seed_and_validate.py index ee5d235..c1282f7 100644 --- a/testing/seed_and_validate.py +++ b/testing/seed_and_validate.py @@ -185,6 +185,175 @@ def verify_seed_sizes(test_conn: psycopg.Connection) -> None: ) +# --------------------------------------------------------------------------- +# Live session threads +# Each thread opens its own psycopg 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 = psycopg.connect(**{**params, "dbname": TEST_DB}) + conn.autocommit = False + conn.execute( + "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 = psycopg.connect(**{**params, "dbname": TEST_DB}) + conn.autocommit = False + try: + conn.execute( + "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 = psycopg.connect(**{**params, "dbname": TEST_DB}) + conn.autocommit = False + conn.execute("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, stop: threading.Event) -> None: + """Run a long-sleeping query — triggers Long Running Queries checks.""" + conn = psycopg.connect(**{**params, "dbname": TEST_DB}) + try: + conn.execute("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: psycopg.Connection, timeout: int = 30) -> bool: + """Wait until at least one session is waiting on a lock in TEST_DB.""" + for _ in range(timeout): + row = admin_conn.execute( + "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,), + ).fetchone() + if row: + return True + time.sleep(1) + return False + + +def _wait_for_state( + admin_conn: psycopg.Connection, state: str, timeout: int = 30 +) -> bool: + """Wait until at least one session in TEST_DB has the given state.""" + for _ in range(timeout): + row = admin_conn.execute( + "SELECT 1 FROM pg_stat_activity " + "WHERE state = %s AND datname = %s AND pid <> pg_backend_pid()", + (state, TEST_DB), + ).fetchone() + if row: + return True + time.sleep(1) + return False + + +def _wait_for_active_query( + admin_conn: psycopg.Connection, 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 = admin_conn.execute( + "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), + ).fetchone() + if row: + return True + time.sleep(1) + return False + + +def start_session_threads( + params: dict, admin_conn: psycopg.Connection +) -> 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, stop), 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 + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Seed and validate all pgFirstAid health checks" From 9b30f1a0982421dc789f62e6ef5a0b096cae71f1 Mon Sep 17 00:00:00 2001 From: randoneering Date: Thu, 16 Apr 2026 04:19:56 +0000 Subject: [PATCH 11/23] feat(seed): add validation logic, expected check sets, and reporting --- testing/seed_and_validate.py | 153 ++++++++++++++++++++++++++++++ testing/test_seed_and_validate.py | 32 ++++++- 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/testing/seed_and_validate.py b/testing/seed_and_validate.py index c1282f7..4d5d2c0 100644 --- a/testing/seed_and_validate.py +++ b/testing/seed_and_validate.py @@ -354,6 +354,159 @@ def start_session_threads( 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", + "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", +}) + + +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: psycopg.Connection, + replication_slot_created: bool, + pss_seeded: bool, +) -> bool: + """Run pg_firstAid() and compare results to the expected check set. + + Returns True if all non-skipped expected checks fired, False otherwise. + """ + rows = test_conn.execute( + "SELECT check_name, count(*) FROM pg_firstAid() GROUP BY check_name" + ).fetchall() + fired: set[str] = {row[0] for row in rows} + + expected = set(_ALWAYS_FIRE) | set(_STATIC_CHECKS) | set(_SESSION_CHECKS) + + skipped = set(_NEVER_SEEDED) + + if pss_seeded: + expected |= set(_PSS_WORKLOAD_CHECKS) + 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" diff --git a/testing/test_seed_and_validate.py b/testing/test_seed_and_validate.py index 9488362..9dc2ec4 100644 --- a/testing/test_seed_and_validate.py +++ b/testing/test_seed_and_validate.py @@ -4,7 +4,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) -from seed_and_validate import get_conn_params, patch_thresholds, PG_FIRSTAID_SQL +from seed_and_validate import get_conn_params, patch_thresholds, PG_FIRSTAID_SQL, build_report def _args(**kwargs): @@ -77,3 +77,33 @@ def test_patch_does_not_modify_file(tmp_path): original = PG_FIRSTAID_SQL.read_text() patch_thresholds(original) assert PG_FIRSTAID_SQL.read_text() == original + + +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 From 290cc5e1851a7fb72e724801319259210a67f85c Mon Sep 17 00:00:00 2001 From: randoneering Date: Thu, 16 Apr 2026 04:20:23 +0000 Subject: [PATCH 12/23] feat(seed): wire main() orchestrator with full seed-validate-cleanup flow --- testing/seed_and_validate.py | 77 +++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/testing/seed_and_validate.py b/testing/seed_and_validate.py index 4d5d2c0..e40a400 100644 --- a/testing/seed_and_validate.py +++ b/testing/seed_and_validate.py @@ -518,7 +518,82 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() -if __name__ == "__main__": +def main() -> int: + """Orchestrate the full seed-and-validate run. Returns exit code (0 = pass).""" args = parse_args() params = get_conn_params(args) + print(f"Connecting to {params['user']}@{params['host']}:{params['port']}") + + admin_conn = connect_admin(params) + test_conn: psycopg.Connection | None = None + replication_slot_created = 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) + + # --- Static seed ------------------------------------------------------ + print("Seeding structural checks (01_seed_static_checks.sql)...") + run_sql_file(test_conn, SEED_DIR / "01_seed_static_checks.sql") + verify_seed_sizes(test_conn) + + # --- pg_stat_statements seed (via psql for \gexec support) ----------- + print("Seeding pg_stat_statements workload (02_seed_pg_stat_statements.sql)...") + pss_seeded = run_psql_file(params, SEED_DIR / "02_seed_pg_stat_statements.sql") + + # --- 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") + + # --- Validate --------------------------------------------------------- + print("Running pg_firstAid() validation...") + success = run_validation(test_conn, replication_slot_created, pss_seeded) + + 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) + admin_conn.close() + + return 0 if success else 1 + + +if __name__ == "__main__": + sys.exit(main()) From a2f923e10587b5c3ead18fc5518b030771dafda5 Mon Sep 17 00:00:00 2001 From: randoneering Date: Sun, 19 Apr 2026 03:27:20 +0000 Subject: [PATCH 13/23] fix(seed): handle pg_stat_statements installed-but-not-loadable edge case pgfirstaid_pg_stat_statements_checks() now wraps all RETURN QUERY statements in a begin/exception block to gracefully handle ObjectNotInPrerequisiteState when the extension is installed but not in shared_preload_libraries. seed_and_validate.py gains is_pss_queryable() to detect this condition, tracks pss_extension_installed separately from pss_seeded, and re-verifies PSS accessibility before validation to guard against connection state changes during the 6-minute session wait. run_validation() skips the "Extension Missing" check when the extension is installed but not queryable. --- pgFirstAid.sql | 4 +++ testing/seed_and_validate.py | 47 ++++++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) 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/testing/seed_and_validate.py b/testing/seed_and_validate.py index e40a400..3908bcf 100644 --- a/testing/seed_and_validate.py +++ b/testing/seed_and_validate.py @@ -135,9 +135,24 @@ def run_psql_file(params: dict, path: Path) -> bool: 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: psycopg.Connection) -> 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: + test_conn.execute("SELECT 1 FROM pg_stat_statements LIMIT 0") + return True + except psycopg.Error: + return False + + def try_create_replication_slot(test_conn: psycopg.Connection) -> bool: """Create a logical replication slot to trigger the inactive-slot check. @@ -463,6 +478,7 @@ def run_validation( test_conn: psycopg.Connection, replication_slot_created: bool, pss_seeded: bool, + pss_extension_installed: bool = False, ) -> bool: """Run pg_firstAid() and compare results to the expected check set. @@ -479,6 +495,10 @@ def run_validation( 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) @@ -528,6 +548,8 @@ def main() -> int: admin_conn = connect_admin(params) test_conn: psycopg.Connection | None = None replication_slot_created = False + pss_extension_installed = False + pss_seeded = False stop_event: threading.Event | None = None success = False @@ -548,7 +570,11 @@ def main() -> int: # --- pg_stat_statements seed (via psql for \gexec support) ----------- print("Seeding pg_stat_statements workload (02_seed_pg_stat_statements.sql)...") - pss_seeded = run_psql_file(params, SEED_DIR / "02_seed_pg_stat_statements.sql") + pss_extension_installed = run_psql_file(params, SEED_DIR / "02_seed_pg_stat_statements.sql") + pss_seeded = pss_extension_installed + if pss_seeded and not is_pss_queryable(test_conn): + print(" SKIP: pg_stat_statements not in shared_preload_libraries — PSS checks not seeded") + pss_seeded = False # --- Replication slot ------------------------------------------------- print("Attempting replication slot seeding...") @@ -572,9 +598,26 @@ def main() -> int: 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 = test_conn.execute( + "SELECT count(*) FROM pg_stat_statements" + ).fetchone() + print(f" PSS diagnostic: {row[0]} total entries in pg_stat_statements") + except psycopg.Error as e: + print(f" PSS diagnostic: query failed ({e}) — downgrading pss_seeded") + pss_seeded = False + # --- Validate --------------------------------------------------------- print("Running pg_firstAid() validation...") - success = run_validation(test_conn, replication_slot_created, pss_seeded) + success = run_validation( + test_conn, replication_slot_created, pss_seeded, pss_extension_installed + ) finally: # Signal threads to stop and allow them to rollback cleanly. From c88782596ad9e6244ddd6be2bab9a72617998d14 Mon Sep 17 00:00:00 2001 From: randoneering Date: Sun, 19 Apr 2026 03:28:11 +0000 Subject: [PATCH 14/23] chore: uv.lock --- uv.lock | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 uv.lock diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..ec6f99e --- /dev/null +++ b/uv.lock @@ -0,0 +1,166 @@ +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 = "psycopg", extra = ["binary"] }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "psycopg", extras = ["binary"], specifier = ">=3.1" }, + { 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 = "psycopg" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/c0/b389119dd754483d316805260f3e73cdcad97925839107cc7a296f6132b1/psycopg_binary-3.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a89bb9ee11177b2995d87186b1d9fa892d8ea725e85eab28c6525e4cc14ee048", size = 4609740, upload-time = "2026-02-18T16:47:51.093Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9976eef20f61840285174d360da4c820a311ab39d6b82fa09fbb545be825/psycopg_binary-3.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f7d0cf072c6fbac3795b08c98ef9ea013f11db609659dcfc6b1f6cc31f9e181", size = 4676837, upload-time = "2026-02-18T16:47:55.523Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f2/d28ba2f7404fd7f68d41e8a11df86313bd646258244cb12a8dd83b868a97/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:90eecd93073922f085967f3ed3a98ba8c325cbbc8c1a204e300282abd2369e13", size = 5497070, upload-time = "2026-02-18T16:47:59.929Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/6c5c54b815edeb30a281cfcea96dc93b3bb6be939aea022f00cab7aa1420/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dac7ee2f88b4d7bb12837989ca354c38d400eeb21bce3b73dac02622f0a3c8d6", size = 5172410, upload-time = "2026-02-18T16:48:05.665Z" }, + { url = "https://files.pythonhosted.org/packages/51/75/8206c7008b57de03c1ada46bd3110cc3743f3fd9ed52031c4601401d766d/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b62cf8784eb6d35beaee1056d54caf94ec6ecf2b7552395e305518ab61eb8fd2", size = 6763408, upload-time = "2026-02-18T16:48:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5a/ea1641a1e6c8c8b3454b0fcb43c3045133a8b703e6e824fae134088e63bd/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a39f34c9b18e8f6794cca17bfbcd64572ca2482318db644268049f8c738f35a6", size = 5006255, upload-time = "2026-02-18T16:48:22.176Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fb/538df099bf55ae1637d52d7ccb6b9620b535a40f4c733897ac2b7bb9e14c/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:883d68d48ca9ff3cb3d10c5fdebea02c79b48eecacdddbf7cce6e7cdbdc216b8", size = 4532694, upload-time = "2026-02-18T16:48:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d1/00780c0e187ea3c13dfc53bd7060654b2232cd30df562aac91a5f1c545ac/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cab7bc3d288d37a80aa8c0820033250c95e40b1c2b5c57cf59827b19c2a8b69d", size = 4222833, upload-time = "2026-02-18T16:48:31.221Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/a07f1ff713c51d64dc9f19f2c32be80299a2055d5d109d5853662b922cb4/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:56c767007ca959ca32f796b42379fc7e1ae2ed085d29f20b05b3fc394f3715cc", size = 3952818, upload-time = "2026-02-18T16:48:35.869Z" }, + { url = "https://files.pythonhosted.org/packages/d3/67/d33f268a7759b4445f3c9b5a181039b01af8c8263c865c1be7a6444d4749/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:da2f331a01af232259a21573a01338530c6016dcfad74626c01330535bcd8628", size = 4258061, upload-time = "2026-02-18T16:48:41.365Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3b/0d8d2c5e8e29ccc07d28c8af38445d9d9abcd238d590186cac82ee71fc84/psycopg_binary-3.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:19f93235ece6dbfc4036b5e4f6d8b13f0b8f2b3eeb8b0bd2936d406991bcdd40", size = 3558915, upload-time = "2026-02-18T16:48:46.679Z" }, + { url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" }, + { url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" }, + { url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" }, + { url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" }, + { url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" }, + { url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" }, + { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" }, + { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" }, + { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" }, + { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" }, + { url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" }, + { url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" }, + { url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" }, + { url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" }, + { url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, +] + +[[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" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, +] From ecc370845b9b114dee39df1dba15750f14ae98e0 Mon Sep 17 00:00:00 2001 From: randoneering Date: Sun, 19 Apr 2026 03:39:18 +0000 Subject: [PATCH 15/23] feat(seed): add --managed flag to test view_pgFirstAid_managed.sql Installs view_pgFirstAid_managed.sql and queries v_pgfirstAid instead of pg_firstaid() when --managed is passed. Allows validating the managed service path (no superuser-only queries) with the same seed data. --- testing/seed_and_validate.py | 41 ++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/testing/seed_and_validate.py b/testing/seed_and_validate.py index 3908bcf..ea13233 100644 --- a/testing/seed_and_validate.py +++ b/testing/seed_and_validate.py @@ -28,6 +28,7 @@ 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. @@ -100,9 +101,14 @@ def drop_test_db(admin_conn: psycopg.Connection) -> None: admin_conn.execute(f"DROP DATABASE IF EXISTS {TEST_DB}") -def install_function(test_conn: psycopg.Connection) -> None: - """Read pgFirstAid.sql, patch thresholds, and install into test DB.""" - sql = PG_FIRSTAID_SQL.read_text() +def install_function(test_conn: psycopg.Connection, 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) test_conn.execute(patched) @@ -479,14 +485,18 @@ def run_validation( replication_slot_created: bool, pss_seeded: bool, pss_extension_installed: bool = False, + managed: bool = False, ) -> bool: - """Run pg_firstAid() and compare results to the expected check set. + """Run pg_firstAid() or SELECT from v_pgfirstAid and compare to expected checks. Returns True if all non-skipped expected checks fired, False otherwise. """ - rows = test_conn.execute( - "SELECT check_name, count(*) FROM pg_firstAid() GROUP BY check_name" - ).fetchall() + 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 = test_conn.execute(query).fetchall() fired: set[str] = {row[0] for row in rows} expected = set(_ALWAYS_FIRE) | set(_STATIC_CHECKS) | set(_SESSION_CHECKS) @@ -535,6 +545,12 @@ def parse_args() -> argparse.Namespace: 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() @@ -543,7 +559,9 @@ def main() -> int: args = parse_args() params = get_conn_params(args) - print(f"Connecting to {params['user']}@{params['host']}:{params['port']}") + 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: psycopg.Connection | None = None @@ -561,7 +579,7 @@ def main() -> int: test_conn = connect_test(params) print("Installing pgFirstAid with patched thresholds...") - install_function(test_conn) + install_function(test_conn, managed=managed) # --- Static seed ------------------------------------------------------ print("Seeding structural checks (01_seed_static_checks.sql)...") @@ -614,9 +632,10 @@ def main() -> int: pss_seeded = False # --- Validate --------------------------------------------------------- - print("Running pg_firstAid() validation...") + 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 + test_conn, replication_slot_created, pss_seeded, pss_extension_installed, managed ) finally: From 3163988eeb703b4c629ea4d6162061401d6529c8 Mon Sep 17 00:00:00 2001 From: randoneering Date: Sun, 19 Apr 2026 22:17:01 +0000 Subject: [PATCH 16/23] resolve false flags and skipped tests --- .../01_seed_static_checks.sql | 32 +- testing/seed_and_validate.py | 329 +++++++++++++----- testing/test_seed_and_validate.py | 214 +++++++++++- 3 files changed, 473 insertions(+), 102 deletions(-) diff --git a/testing/healthcheck_seed/01_seed_static_checks.sql b/testing/healthcheck_seed/01_seed_static_checks.sql index 6157c88..d43165b 100644 --- a/testing/healthcheck_seed/01_seed_static_checks.sql +++ b/testing/healthcheck_seed/01_seed_static_checks.sql @@ -243,7 +243,7 @@ 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. --- Scanned exactly 5 times (1 < 5 < 100). +-- 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, @@ -256,17 +256,25 @@ CREATE INDEX pgfirstaid_seed_low_usage_idx ON pgfirstaid_seed.low_usage_idx_table (search_key); ANALYZE pgfirstaid_seed.low_usage_idx_table; -DO $$ -DECLARE - dummy bigint; -BEGIN - FOR i IN 1..5 LOOP - SELECT id INTO dummy - FROM pgfirstaid_seed.low_usage_idx_table - WHERE search_key = md5(i::text) - LIMIT 1; - END LOOP; -END $$; +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). diff --git a/testing/seed_and_validate.py b/testing/seed_and_validate.py index ea13233..a128aad 100644 --- a/testing/seed_and_validate.py +++ b/testing/seed_and_validate.py @@ -132,6 +132,8 @@ def run_psql_file(params: dict, path: Path) -> bool: 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) @@ -159,6 +161,24 @@ def is_pss_queryable(test_conn: psycopg.Connection) -> bool: return False +def is_extension_installed(test_conn: psycopg.Connection, extension_name: str) -> bool: + """Return True if the named extension exists in pg_extension.""" + row = test_conn.execute( + "SELECT 1 FROM pg_extension WHERE extname = %s", + (extension_name,), + ).fetchone() + return row is not None + + +def classify_pss_state( + test_conn: psycopg.Connection, 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: psycopg.Connection) -> bool: """Create a logical replication slot to trigger the inactive-slot check. @@ -172,19 +192,32 @@ def try_create_replication_slot(test_conn: psycopg.Connection) -> bool: ) return True except psycopg.errors.ObjectNotInPrerequisiteState: - print(" SKIP: wal_level != logical — Inactive Replication Slots check not seeded") + print( + " SKIP: wal_level != logical — Inactive Replication Slots check not seeded" + ) return False except psycopg.errors.InsufficientPrivilege: - print(" SKIP: insufficient privilege — Inactive Replication Slots check not seeded") + print( + " SKIP: insufficient privilege — Inactive Replication Slots check not seeded" + ) return False + except psycopg.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: psycopg.Connection) -> None: """Drop the test replication slot if it exists.""" try: - test_conn.execute( - "SELECT pg_drop_replication_slot('pgfirstaid_test_slot')" - ) + test_conn.execute("SELECT pg_drop_replication_slot('pgfirstaid_test_slot')") except Exception: pass @@ -198,7 +231,9 @@ def verify_seed_sizes(test_conn: psycopg.Connection) -> None: """).fetchone() 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") + 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 — " @@ -206,6 +241,34 @@ def verify_seed_sizes(test_conn: psycopg.Connection) -> None: ) +def seed_low_usage_index_scans(test_conn: psycopg.Connection) -> None: + """Run low-cardinality index lookups as separate statements so stats record them.""" + for value in range(1, 6): + test_conn.execute( + "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: psycopg.Connection, + 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 = test_conn.execute( + "SELECT idx_scan FROM pg_stat_user_indexes WHERE indexrelname = %s", + (index_name,), + ).fetchone() + if row and row[0] >= min_scans: + return True + time.sleep(1.0) + return False + + # --------------------------------------------------------------------------- # Live session threads # Each thread opens its own psycopg connection and holds it open to trigger @@ -213,7 +276,10 @@ def verify_seed_sizes(test_conn: psycopg.Connection) -> None: # automatically killed when the main process exits. # --------------------------------------------------------------------------- -def _blocker_thread(params: dict, ready: threading.Event, stop: threading.Event) -> None: + +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 = psycopg.connect(**{**params, "dbname": TEST_DB}) conn.autocommit = False @@ -249,7 +315,9 @@ def _blocked_thread(params: dict, blocker_ready: threading.Event) -> None: conn.close() -def _idle_in_txn_thread(params: dict, ready: threading.Event, stop: threading.Event) -> None: +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 = psycopg.connect(**{**params, "dbname": TEST_DB}) conn.autocommit = False @@ -278,6 +346,7 @@ def _long_query_thread(params: dict, stop: threading.Event) -> None: # Wait helpers — poll pg_stat_activity / pg_locks from the admin connection # --------------------------------------------------------------------------- + def _wait_for_blocked(admin_conn: psycopg.Connection, timeout: int = 30) -> bool: """Wait until at least one session is waiting on a lock in TEST_DB.""" for _ in range(timeout): @@ -349,9 +418,7 @@ def start_session_threads( threading.Thread( target=_idle_in_txn_thread, args=(params, idle_ready, stop), daemon=True ), - threading.Thread( - target=_long_query_thread, args=(params, stop), daemon=True - ), + threading.Thread(target=_long_query_thread, args=(params, stop), daemon=True), ] print(" Starting blocker thread...") @@ -381,78 +448,127 @@ def start_session_threads( # --------------------------------------------------------------------------- # 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", -}) +_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", -}) +_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", -}) +_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", - "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", -}) +_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", -}) +_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", -}) +_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: psycopg.Connection, +) -> 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 = test_conn.execute( + "SELECT pg_size_bytes(current_setting(%s)) = pg_size_bytes(%s)", + (setting_name, default_value), + ).fetchone() + if row and row[0]: + expected.add(check_name) + else: + skipped.add(check_name) + + return expected, skipped def build_report( @@ -503,6 +619,13 @@ def run_validation( 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: @@ -541,10 +664,18 @@ 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( + "--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", @@ -561,7 +692,9 @@ def main() -> int: 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}]") + print( + f"Connecting to {params['user']}@{params['host']}:{params['port']} [{mode_label}]" + ) admin_conn = connect_admin(params) test_conn: psycopg.Connection | None = None @@ -584,15 +717,25 @@ def main() -> int: # --- 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)...") - pss_extension_installed = run_psql_file(params, SEED_DIR / "02_seed_pg_stat_statements.sql") - pss_seeded = pss_extension_installed - if pss_seeded and not is_pss_queryable(test_conn): - print(" SKIP: pg_stat_statements not in shared_preload_libraries — PSS checks not seeded") - pss_seeded = False + 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...") @@ -603,9 +746,13 @@ def main() -> int: _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)...") + 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") + 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( @@ -619,23 +766,33 @@ def main() -> int: # --- 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") + print( + " PSS diagnostic: pg_stat_statements not accessible — downgrading pss_seeded" + ) pss_seeded = False else: try: row = test_conn.execute( "SELECT count(*) FROM pg_stat_statements" ).fetchone() - print(f" PSS diagnostic: {row[0]} total entries in pg_stat_statements") + print( + f" PSS diagnostic: {row[0]} total entries in pg_stat_statements" + ) except psycopg.Error as e: - print(f" PSS diagnostic: query failed ({e}) — downgrading pss_seeded") + 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 + test_conn, + replication_slot_created, + pss_seeded, + pss_extension_installed, + managed, ) finally: diff --git a/testing/test_seed_and_validate.py b/testing/test_seed_and_validate.py index 9dc2ec4..7b94792 100644 --- a/testing/test_seed_and_validate.py +++ b/testing/test_seed_and_validate.py @@ -1,10 +1,36 @@ import argparse import os +import re +import subprocess import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) -from seed_and_validate import get_conn_params, patch_thresholds, PG_FIRSTAID_SQL, build_report +import psycopg + +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): @@ -13,9 +39,7 @@ def _args(**kwargs): 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()] - ) + return parser.parse_args([f"--{k}={v}" for k, v in kwargs.items()]) def test_get_conn_params_defaults(monkeypatch): @@ -107,3 +131,185 @@ def test_build_report_skipped_not_in_failed(): 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 psycopg.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 psycopg.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_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)] From 56856c89d7f868a4f15f0b2c0db4f398578c55ab Mon Sep 17 00:00:00 2001 From: randoneering Date: Sun, 19 Apr 2026 22:42:45 +0000 Subject: [PATCH 17/23] added seed/validation testing into workflow --- .github/workflows/README.md | 2 +- .github/workflows/integration-pg-matrix.yml | 3 +++ .github/workflows/managed-db-validate.yml | 3 +++ README.md | 1 + testing/integration/README.md | 2 ++ testing/seed_and_validate.py | 1 + testing/test_seed_and_validate.py | 8 ++++++++ 7 files changed, 19 insertions(+), 1 deletion(-) 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..f9a6b70 100644 --- a/.github/workflows/integration-pg-matrix.yml +++ b/.github/workflows/integration-pg-matrix.yml @@ -91,3 +91,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..4351260 100644 --- a/.github/workflows/managed-db-validate.yml +++ b/.github/workflows/managed-db-validate.yml @@ -169,3 +169,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/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/seed_and_validate.py b/testing/seed_and_validate.py index a128aad..a46e94b 100644 --- a/testing/seed_and_validate.py +++ b/testing/seed_and_validate.py @@ -64,6 +64,7 @@ def get_conn_params(args: argparse.Namespace) -> dict: "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", } diff --git a/testing/test_seed_and_validate.py b/testing/test_seed_and_validate.py index 7b94792..9519c5c 100644 --- a/testing/test_seed_and_validate.py +++ b/testing/test_seed_and_validate.py @@ -60,11 +60,13 @@ def test_get_conn_params_env(monkeypatch): 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): @@ -75,6 +77,12 @@ def test_get_conn_params_cli_overrides_env(monkeypatch): 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) From 0bd730a3b2a73a7d2ad5ba152dee6bf6f8f01cb8 Mon Sep 17 00:00:00 2001 From: randoneering Date: Tue, 21 Apr 2026 02:26:31 +0000 Subject: [PATCH 18/23] resolving comments/reviews from greptile/qodo --- .github/workflows/integration-pg-matrix.yml | 2 +- pyproject.toml | 2 +- .../healthcheck_seed/04_session_blocked.sql | 16 ++ .../05_session_idle_in_transaction.sql | 5 +- .../07_pgbench_active_query.sql | 7 +- testing/integration/tests/conftest.py | 10 + testing/seed_and_validate.py | 241 ++++++++++++------ testing/test_seed_and_validate.py | 27 +- uv.lock | 132 ++++------ view_pgFirstAid_managed.sql | 4 + 10 files changed, 277 insertions(+), 169 deletions(-) diff --git a/.github/workflows/integration-pg-matrix.yml b/.github/workflows/integration-pg-matrix.yml index f9a6b70..5682599 100644 --- a/.github/workflows/integration-pg-matrix.yml +++ b/.github/workflows/integration-pg-matrix.yml @@ -51,7 +51,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: "3.14" + python-version: "3.12" - name: Install uv uses: astral-sh/setup-uv@v4 diff --git a/pyproject.toml b/pyproject.toml index 123fa50..f7b02f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "pgfirstaid-testing" version = "0.1.0" requires-python = ">=3.11" dependencies = [ - "psycopg[binary]>=3.1", + "psycopg2-binary>=2.9.12", "pytest>=8.0", ] diff --git a/testing/healthcheck_seed/04_session_blocked.sql b/testing/healthcheck_seed/04_session_blocked.sql index e69de29..faa8ed6 100644 --- a/testing/healthcheck_seed/04_session_blocked.sql +++ 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 index 81cbff1..48683b9 100644 --- a/testing/healthcheck_seed/05_session_idle_in_transaction.sql +++ b/testing/healthcheck_seed/05_session_idle_in_transaction.sql @@ -1 +1,4 @@ -SELECT pg_sleep(360); +BEGIN; +SELECT 1; +\prompt 'Session is idle in transaction. Press enter to rollback and exit: ' dummy +ROLLBACK; diff --git a/testing/healthcheck_seed/07_pgbench_active_query.sql b/testing/healthcheck_seed/07_pgbench_active_query.sql index 7ec991a..8392fe7 100644 --- a/testing/healthcheck_seed/07_pgbench_active_query.sql +++ b/testing/healthcheck_seed/07_pgbench_active_query.sql @@ -1,7 +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/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 index a46e94b..dbb32d5 100644 --- a/testing/seed_and_validate.py +++ b/testing/seed_and_validate.py @@ -12,7 +12,7 @@ Requires superuser or CREATEDB + CREATE ROLE privileges. Dependency: - pip install "psycopg[binary]" + pip install psycopg2-binary """ import argparse @@ -22,9 +22,12 @@ import sys import threading import time +from typing import Any from pathlib import Path -import psycopg +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" @@ -54,7 +57,7 @@ def patch_thresholds(sql: str) -> str: def get_conn_params(args: argparse.Namespace) -> dict: - """Build psycopg connection kwargs from CLI args and env vars. + """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). @@ -69,40 +72,106 @@ def get_conn_params(args: argparse.Namespace) -> dict: } -def connect_admin(params: dict) -> psycopg.Connection: +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 psycopg.connect(**params, autocommit=True) + return _connect(params, autocommit=True) -def connect_test(params: dict) -> psycopg.Connection: +def connect_test(params: dict[str, Any]) -> PgConnection: """Connect to the test database with autocommit for DDL.""" test_params = {**params, "dbname": TEST_DB} - return psycopg.connect(**test_params, autocommit=True) + return _connect(test_params, autocommit=True) -def create_test_db(admin_conn: psycopg.Connection) -> None: +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. - admin_conn.execute( + _execute( + admin_conn, "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " "WHERE datname = %s AND pid <> pg_backend_pid()", (TEST_DB,), ) - admin_conn.execute(f"DROP DATABASE IF EXISTS {TEST_DB}") - admin_conn.execute(f"CREATE DATABASE {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: psycopg.Connection) -> None: +def drop_test_db(admin_conn: PgConnection) -> None: """Terminate all connections to the test database and drop it.""" - admin_conn.execute( + _execute( + admin_conn, "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " "WHERE datname = %s AND pid <> pg_backend_pid()", (TEST_DB,), ) - admin_conn.execute(f"DROP DATABASE IF EXISTS {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: psycopg.Connection, managed: bool = False) -> None: +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 @@ -111,12 +180,12 @@ def install_function(test_conn: psycopg.Connection, managed: bool = False) -> No sql_file = PG_FIRSTAID_MANAGED_SQL if managed else PG_FIRSTAID_SQL sql = sql_file.read_text() patched = patch_thresholds(sql) - test_conn.execute(patched) + _execute(test_conn, patched) -def run_sql_file(test_conn: psycopg.Connection, path: Path) -> None: +def run_sql_file(test_conn: PgConnection, path: Path) -> None: """Execute a plain SQL file against the test connection.""" - test_conn.execute(path.read_text()) + _execute(test_conn, path.read_text()) def run_psql_file(params: dict, path: Path) -> bool: @@ -149,30 +218,31 @@ def run_psql_file(params: dict, path: Path) -> bool: return True -def is_pss_queryable(test_conn: psycopg.Connection) -> bool: +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: - test_conn.execute("SELECT 1 FROM pg_stat_statements LIMIT 0") + _execute(test_conn, "SELECT 1 FROM pg_stat_statements LIMIT 0") return True - except psycopg.Error: + except Error: return False -def is_extension_installed(test_conn: psycopg.Connection, extension_name: str) -> bool: +def is_extension_installed(test_conn: PgConnection, extension_name: str) -> bool: """Return True if the named extension exists in pg_extension.""" - row = test_conn.execute( + row = _fetchone( + test_conn, "SELECT 1 FROM pg_extension WHERE extname = %s", (extension_name,), - ).fetchone() + ) return row is not None def classify_pss_state( - test_conn: psycopg.Connection, psql_seed_succeeded: bool + 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") @@ -180,29 +250,30 @@ def classify_pss_state( return installed, seeded -def try_create_replication_slot(test_conn: psycopg.Connection) -> bool: +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: - test_conn.execute( + _execute( + test_conn, "SELECT pg_create_logical_replication_slot(" - " 'pgfirstaid_test_slot', 'test_decoding')" + " 'pgfirstaid_test_slot', 'test_decoding')", ) return True - except psycopg.errors.ObjectNotInPrerequisiteState: + except errors.ObjectNotInPrerequisiteState: print( " SKIP: wal_level != logical — Inactive Replication Slots check not seeded" ) return False - except psycopg.errors.InsufficientPrivilege: + except errors.InsufficientPrivilege: print( " SKIP: insufficient privilege — Inactive Replication Slots check not seeded" ) return False - except psycopg.Error as error: + 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 @@ -215,21 +286,24 @@ def try_create_replication_slot(test_conn: psycopg.Connection) -> bool: raise -def drop_replication_slot(test_conn: psycopg.Connection) -> None: +def drop_replication_slot(test_conn: PgConnection) -> None: """Drop the test replication slot if it exists.""" try: - test_conn.execute("SELECT pg_drop_replication_slot('pgfirstaid_test_slot')") + _execute(test_conn, "SELECT pg_drop_replication_slot('pgfirstaid_test_slot')") except Exception: pass -def verify_seed_sizes(test_conn: psycopg.Connection) -> None: +def verify_seed_sizes(test_conn: PgConnection) -> None: """Warn if size-seeded tables are outside expected ranges after patching.""" - row = test_conn.execute(""" + 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 - """).fetchone() + """, + ) large_bytes, medium_bytes = row if large_bytes <= 1_048_576: print( @@ -242,10 +316,11 @@ def verify_seed_sizes(test_conn: psycopg.Connection) -> None: ) -def seed_low_usage_index_scans(test_conn: psycopg.Connection) -> None: +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): - test_conn.execute( + _execute( + test_conn, "SELECT id FROM pgfirstaid_seed.low_usage_idx_table " "WHERE search_key = md5(%s::text) LIMIT 1", (str(value),), @@ -253,17 +328,18 @@ def seed_low_usage_index_scans(test_conn: psycopg.Connection) -> None: def wait_for_index_scan_count( - test_conn: psycopg.Connection, + 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 = test_conn.execute( + row = _fetchone( + test_conn, "SELECT idx_scan FROM pg_stat_user_indexes WHERE indexrelname = %s", (index_name,), - ).fetchone() + ) if row and row[0] >= min_scans: return True time.sleep(1.0) @@ -272,7 +348,7 @@ def wait_for_index_scan_count( # --------------------------------------------------------------------------- # Live session threads -# Each thread opens its own psycopg connection and holds it open to trigger +# 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. # --------------------------------------------------------------------------- @@ -282,10 +358,11 @@ 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 = psycopg.connect(**{**params, "dbname": TEST_DB}) + conn = psycopg2.connect(**{**params, "dbname": TEST_DB}) conn.autocommit = False - conn.execute( - "UPDATE pgfirstaid_seed.lock_target SET payload = 'locked_by_blocker' WHERE id = 1" + _execute( + conn, + "UPDATE pgfirstaid_seed.lock_target SET payload = 'locked_by_blocker' WHERE id = 1", ) ready.set() stop.wait(timeout=700) @@ -300,11 +377,12 @@ 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 = psycopg.connect(**{**params, "dbname": TEST_DB}) + conn = psycopg2.connect(**{**params, "dbname": TEST_DB}) conn.autocommit = False try: - conn.execute( - "UPDATE pgfirstaid_seed.lock_target SET payload = 'blocked' WHERE id = 1" + _execute( + conn, + "UPDATE pgfirstaid_seed.lock_target SET payload = 'blocked' WHERE id = 1", ) except Exception: pass @@ -320,9 +398,9 @@ 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 = psycopg.connect(**{**params, "dbname": TEST_DB}) + conn = psycopg2.connect(**{**params, "dbname": TEST_DB}) conn.autocommit = False - conn.execute("SELECT 1") # Starts the transaction; connection is now idle in txn. + _execute(conn, "SELECT 1") # Starts the transaction; connection is now idle in txn. ready.set() stop.wait(timeout=700) try: @@ -332,11 +410,17 @@ def _idle_in_txn_thread( conn.close() -def _long_query_thread(params: dict, stop: threading.Event) -> None: - """Run a long-sleeping query — triggers Long Running Queries checks.""" - conn = psycopg.connect(**{**params, "dbname": TEST_DB}) +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: - conn.execute("SELECT pg_sleep(700)") + _execute(conn, "SELECT pg_sleep(700)") except Exception: pass finally: @@ -348,31 +432,31 @@ def _long_query_thread(params: dict, stop: threading.Event) -> None: # --------------------------------------------------------------------------- -def _wait_for_blocked(admin_conn: psycopg.Connection, timeout: int = 30) -> bool: +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 = admin_conn.execute( + 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,), - ).fetchone() + ) if row: return True time.sleep(1) return False -def _wait_for_state( - admin_conn: psycopg.Connection, state: str, timeout: int = 30 -) -> bool: +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 = admin_conn.execute( + row = _fetchone( + admin_conn, "SELECT 1 FROM pg_stat_activity " "WHERE state = %s AND datname = %s AND pid <> pg_backend_pid()", (state, TEST_DB), - ).fetchone() + ) if row: return True time.sleep(1) @@ -380,17 +464,18 @@ def _wait_for_state( def _wait_for_active_query( - admin_conn: psycopg.Connection, min_seconds: int = 35, timeout: int = 60 + 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 = admin_conn.execute( + 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), - ).fetchone() + ) if row: return True time.sleep(1) @@ -398,7 +483,7 @@ def _wait_for_active_query( def start_session_threads( - params: dict, admin_conn: psycopg.Connection + 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. @@ -419,7 +504,7 @@ def start_session_threads( threading.Thread( target=_idle_in_txn_thread, args=(params, idle_ready, stop), daemon=True ), - threading.Thread(target=_long_query_thread, args=(params, stop), daemon=True), + threading.Thread(target=_long_query_thread, args=(params,), daemon=True), ] print(" Starting blocker thread...") @@ -553,17 +638,18 @@ def start_session_threads( def classify_default_setting_checks( - test_conn: psycopg.Connection, + 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 = test_conn.execute( + row = _fetchone( + test_conn, "SELECT pg_size_bytes(current_setting(%s)) = pg_size_bytes(%s)", (setting_name, default_value), - ).fetchone() + ) if row and row[0]: expected.add(check_name) else: @@ -598,7 +684,7 @@ def build_report( def run_validation( - test_conn: psycopg.Connection, + test_conn: PgConnection, replication_slot_created: bool, pss_seeded: bool, pss_extension_installed: bool = False, @@ -613,7 +699,7 @@ def run_validation( if managed else "SELECT check_name, count(*) FROM pg_firstAid() GROUP BY check_name" ) - rows = test_conn.execute(query).fetchall() + rows = _fetchall(test_conn, query) fired: set[str] = {row[0] for row in rows} expected = set(_ALWAYS_FIRE) | set(_STATIC_CHECKS) | set(_SESSION_CHECKS) @@ -698,7 +784,7 @@ def main() -> int: ) admin_conn = connect_admin(params) - test_conn: psycopg.Connection | None = None + test_conn: PgConnection | None = None replication_slot_created = False pss_extension_installed = False pss_seeded = False @@ -773,13 +859,13 @@ def main() -> int: pss_seeded = False else: try: - row = test_conn.execute( - "SELECT count(*) FROM pg_stat_statements" - ).fetchone() + row = _fetchone( + test_conn, "SELECT count(*) FROM pg_stat_statements" + ) print( f" PSS diagnostic: {row[0]} total entries in pg_stat_statements" ) - except psycopg.Error as e: + except Error as e: print( f" PSS diagnostic: query failed ({e}) — downgrading pss_seeded" ) @@ -810,6 +896,7 @@ def main() -> int: 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 diff --git a/testing/test_seed_and_validate.py b/testing/test_seed_and_validate.py index 9519c5c..17fceb5 100644 --- a/testing/test_seed_and_validate.py +++ b/testing/test_seed_and_validate.py @@ -6,7 +6,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) -import psycopg +import psycopg2 from seed_and_validate import ( _ALWAYS_FIRE, @@ -111,6 +111,21 @@ def test_patch_does_not_modify_file(tmp_path): 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_build_report_all_pass(): fired = {"Missing Primary Key", "Duplicate Index", "Database Size"} expected = {"Missing Primary Key", "Duplicate Index", "Database Size"} @@ -176,7 +191,7 @@ def fake_run(cmd, **kwargs): def test_try_create_replication_slot_skips_missing_output_plugin(capsys): class FakeConn: def execute(self, _query): - raise psycopg.errors.UndefinedObject( + raise psycopg2.errors.UndefinedObject( 'logical decoding output plugin "test_decoding" does not exist' ) @@ -248,7 +263,7 @@ def execute(self, query, params=None): if "FROM pg_extension" in query: return FakeResult(1) if "FROM pg_stat_statements" in query: - raise psycopg.errors.ObjectNotInPrerequisiteState( + raise psycopg2.errors.ObjectNotInPrerequisiteState( 'pg_stat_statements must be loaded via "shared_preload_libraries"' ) raise AssertionError(query) @@ -259,6 +274,12 @@ def execute(self, query, params=None): 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): diff --git a/uv.lock b/uv.lock index ec6f99e..b5e8df8 100644 --- a/uv.lock +++ b/uv.lock @@ -34,13 +34,13 @@ name = "pgfirstaid-testing" version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "psycopg", extra = ["binary"] }, + { name = "psycopg2-binary" }, { name = "pytest" }, ] [package.metadata] requires-dist = [ - { name = "psycopg", extras = ["binary"], specifier = ">=3.1" }, + { name = "psycopg2-binary", specifier = ">=2.9.12" }, { name = "pytest", specifier = ">=8.0" }, ] @@ -54,72 +54,54 @@ wheels = [ ] [[package]] -name = "psycopg" -version = "3.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, -] - -[package.optional-dependencies] -binary = [ - { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, -] - -[[package]] -name = "psycopg-binary" -version = "3.3.3" +name = "psycopg2-binary" +version = "2.9.12" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/c0/b389119dd754483d316805260f3e73cdcad97925839107cc7a296f6132b1/psycopg_binary-3.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a89bb9ee11177b2995d87186b1d9fa892d8ea725e85eab28c6525e4cc14ee048", size = 4609740, upload-time = "2026-02-18T16:47:51.093Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9976eef20f61840285174d360da4c820a311ab39d6b82fa09fbb545be825/psycopg_binary-3.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f7d0cf072c6fbac3795b08c98ef9ea013f11db609659dcfc6b1f6cc31f9e181", size = 4676837, upload-time = "2026-02-18T16:47:55.523Z" }, - { url = "https://files.pythonhosted.org/packages/9f/f2/d28ba2f7404fd7f68d41e8a11df86313bd646258244cb12a8dd83b868a97/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:90eecd93073922f085967f3ed3a98ba8c325cbbc8c1a204e300282abd2369e13", size = 5497070, upload-time = "2026-02-18T16:47:59.929Z" }, - { url = "https://files.pythonhosted.org/packages/de/2f/6c5c54b815edeb30a281cfcea96dc93b3bb6be939aea022f00cab7aa1420/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dac7ee2f88b4d7bb12837989ca354c38d400eeb21bce3b73dac02622f0a3c8d6", size = 5172410, upload-time = "2026-02-18T16:48:05.665Z" }, - { url = "https://files.pythonhosted.org/packages/51/75/8206c7008b57de03c1ada46bd3110cc3743f3fd9ed52031c4601401d766d/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b62cf8784eb6d35beaee1056d54caf94ec6ecf2b7552395e305518ab61eb8fd2", size = 6763408, upload-time = "2026-02-18T16:48:13.541Z" }, - { url = "https://files.pythonhosted.org/packages/d4/5a/ea1641a1e6c8c8b3454b0fcb43c3045133a8b703e6e824fae134088e63bd/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a39f34c9b18e8f6794cca17bfbcd64572ca2482318db644268049f8c738f35a6", size = 5006255, upload-time = "2026-02-18T16:48:22.176Z" }, - { url = "https://files.pythonhosted.org/packages/aa/fb/538df099bf55ae1637d52d7ccb6b9620b535a40f4c733897ac2b7bb9e14c/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:883d68d48ca9ff3cb3d10c5fdebea02c79b48eecacdddbf7cce6e7cdbdc216b8", size = 4532694, upload-time = "2026-02-18T16:48:27.338Z" }, - { url = "https://files.pythonhosted.org/packages/a1/d1/00780c0e187ea3c13dfc53bd7060654b2232cd30df562aac91a5f1c545ac/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cab7bc3d288d37a80aa8c0820033250c95e40b1c2b5c57cf59827b19c2a8b69d", size = 4222833, upload-time = "2026-02-18T16:48:31.221Z" }, - { url = "https://files.pythonhosted.org/packages/7a/34/a07f1ff713c51d64dc9f19f2c32be80299a2055d5d109d5853662b922cb4/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:56c767007ca959ca32f796b42379fc7e1ae2ed085d29f20b05b3fc394f3715cc", size = 3952818, upload-time = "2026-02-18T16:48:35.869Z" }, - { url = "https://files.pythonhosted.org/packages/d3/67/d33f268a7759b4445f3c9b5a181039b01af8c8263c865c1be7a6444d4749/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:da2f331a01af232259a21573a01338530c6016dcfad74626c01330535bcd8628", size = 4258061, upload-time = "2026-02-18T16:48:41.365Z" }, - { url = "https://files.pythonhosted.org/packages/b4/3b/0d8d2c5e8e29ccc07d28c8af38445d9d9abcd238d590186cac82ee71fc84/psycopg_binary-3.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:19f93235ece6dbfc4036b5e4f6d8b13f0b8f2b3eeb8b0bd2936d406991bcdd40", size = 3558915, upload-time = "2026-02-18T16:48:46.679Z" }, - { url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" }, - { url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" }, - { url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" }, - { url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" }, - { url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" }, - { url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" }, - { url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" }, - { url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" }, - { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" }, - { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" }, - { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" }, - { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" }, - { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" }, - { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" }, - { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" }, - { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" }, - { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" }, - { url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" }, - { url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" }, - { url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" }, - { url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" }, - { url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" }, - { url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" }, - { url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" }, - { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, + { 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]] @@ -146,21 +128,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc 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" }, ] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "tzdata" -version = "2026.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, -] 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; From bf1b3bb0b7cb3cf5e2a0a599b9b872ccc6c3eefb Mon Sep 17 00:00:00 2001 From: randoneering Date: Tue, 21 Apr 2026 02:39:40 +0000 Subject: [PATCH 19/23] runner is now nixos, adjusting workflows --- .github/workflows/integration-pg-matrix.yml | 15 +++++++++------ .github/workflows/managed-db-validate.yml | 15 +++++++++------ testing/test_seed_and_validate.py | 13 +++++++++++++ 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/.github/workflows/integration-pg-matrix.yml b/.github/workflows/integration-pg-matrix.yml index 5682599..81c053c 100644 --- a/.github/workflows/integration-pg-matrix.yml +++ b/.github/workflows/integration-pg-matrix.yml @@ -48,11 +48,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Install uv uses: astral-sh/setup-uv@v4 @@ -77,8 +72,16 @@ jobs: fi psql --version + - name: Verify Python is installed on runner + run: | + if ! command -v python3 >/dev/null 2>&1; then + echo "::error::python3 not found on runner. Install Python on the self-hosted NixOS runner." + exit 1 + fi + python3 --version + - name: Sync dependencies - run: uv sync + run: uv sync --python python3 - name: Install pgFirstAid function run: | diff --git a/.github/workflows/managed-db-validate.yml b/.github/workflows/managed-db-validate.yml index 4351260..d2ae606 100644 --- a/.github/workflows/managed-db-validate.yml +++ b/.github/workflows/managed-db-validate.yml @@ -127,11 +127,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 @@ -156,8 +151,16 @@ jobs: fi psql --version + - name: Verify Python is installed on runner + run: | + if ! command -v python3 >/dev/null 2>&1; then + echo "::error::python3 not found on runner. Install Python on the self-hosted NixOS runner." + exit 1 + fi + python3 --version + - name: Sync dependencies - run: uv sync + run: uv sync --python python3 - name: Install pgFirstAid function run: psql -v ON_ERROR_STOP=1 -f ../../pgFirstAid.sql diff --git a/testing/test_seed_and_validate.py b/testing/test_seed_and_validate.py index 17fceb5..c311a66 100644 --- a/testing/test_seed_and_validate.py +++ b/testing/test_seed_and_validate.py @@ -126,6 +126,19 @@ def test_testing_suite_uses_psycopg2_not_psycopg() -> None: 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 "command -v python3" in workflow + assert "uv sync --python python3" in workflow + + def test_build_report_all_pass(): fired = {"Missing Primary Key", "Duplicate Index", "Database Size"} expected = {"Missing Primary Key", "Duplicate Index", "Database Size"} From 7c2cc0d2604084d3e7f9be5551e03d8fb29f3b4d Mon Sep 17 00:00:00 2001 From: randoneering Date: Tue, 21 Apr 2026 02:59:42 +0000 Subject: [PATCH 20/23] uv added to runner --- .github/workflows/integration-pg-matrix.yml | 11 ++++++++--- .github/workflows/managed-db-validate.yml | 11 ++++++++--- testing/test_seed_and_validate.py | 2 ++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/integration-pg-matrix.yml b/.github/workflows/integration-pg-matrix.yml index 81c053c..6fbfa1d 100644 --- a/.github/workflows/integration-pg-matrix.yml +++ b/.github/workflows/integration-pg-matrix.yml @@ -48,9 +48,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Install uv - uses: astral-sh/setup-uv@v4 - - name: Validate required PG env vars run: | missing=0 @@ -80,6 +77,14 @@ jobs: fi python3 --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 --python python3 diff --git a/.github/workflows/managed-db-validate.yml b/.github/workflows/managed-db-validate.yml index d2ae606..3fbf0c0 100644 --- a/.github/workflows/managed-db-validate.yml +++ b/.github/workflows/managed-db-validate.yml @@ -127,9 +127,6 @@ jobs: echo "PGHOST=$host" >> "$GITHUB_ENV" - - name: Install uv - uses: astral-sh/setup-uv@v4 - - name: Validate required PG env vars run: | missing=0 @@ -159,6 +156,14 @@ jobs: fi python3 --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 --python python3 diff --git a/testing/test_seed_and_validate.py b/testing/test_seed_and_validate.py index c311a66..566abf0 100644 --- a/testing/test_seed_and_validate.py +++ b/testing/test_seed_and_validate.py @@ -135,7 +135,9 @@ def test_integration_workflows_use_runner_python_on_self_hosted() -> None: ]: 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 "command -v python3" in workflow + assert "command -v uv" in workflow assert "uv sync --python python3" in workflow From 4db33c58391458b89a14dc03546ec6d149b7afc1 Mon Sep 17 00:00:00 2001 From: randoneering Date: Tue, 21 Apr 2026 03:05:55 +0000 Subject: [PATCH 21/23] further troubleshooting --- .github/workflows/integration-pg-matrix.yml | 10 +--------- .github/workflows/managed-db-validate.yml | 10 +--------- testing/test_seed_and_validate.py | 4 ++-- 3 files changed, 4 insertions(+), 20 deletions(-) diff --git a/.github/workflows/integration-pg-matrix.yml b/.github/workflows/integration-pg-matrix.yml index 6fbfa1d..5ca215b 100644 --- a/.github/workflows/integration-pg-matrix.yml +++ b/.github/workflows/integration-pg-matrix.yml @@ -69,14 +69,6 @@ jobs: fi psql --version - - name: Verify Python is installed on runner - run: | - if ! command -v python3 >/dev/null 2>&1; then - echo "::error::python3 not found on runner. Install Python on the self-hosted NixOS runner." - exit 1 - fi - python3 --version - - name: Verify uv is installed on runner run: | if ! command -v uv >/dev/null 2>&1; then @@ -86,7 +78,7 @@ jobs: uv --version - name: Sync dependencies - run: uv sync --python python3 + run: uv sync - name: Install pgFirstAid function run: | diff --git a/.github/workflows/managed-db-validate.yml b/.github/workflows/managed-db-validate.yml index 3fbf0c0..a48e684 100644 --- a/.github/workflows/managed-db-validate.yml +++ b/.github/workflows/managed-db-validate.yml @@ -148,14 +148,6 @@ jobs: fi psql --version - - name: Verify Python is installed on runner - run: | - if ! command -v python3 >/dev/null 2>&1; then - echo "::error::python3 not found on runner. Install Python on the self-hosted NixOS runner." - exit 1 - fi - python3 --version - - name: Verify uv is installed on runner run: | if ! command -v uv >/dev/null 2>&1; then @@ -165,7 +157,7 @@ jobs: uv --version - name: Sync dependencies - run: uv sync --python python3 + run: uv sync - name: Install pgFirstAid function run: psql -v ON_ERROR_STOP=1 -f ../../pgFirstAid.sql diff --git a/testing/test_seed_and_validate.py b/testing/test_seed_and_validate.py index 566abf0..d4fb1ce 100644 --- a/testing/test_seed_and_validate.py +++ b/testing/test_seed_and_validate.py @@ -136,9 +136,9 @@ def test_integration_workflows_use_runner_python_on_self_hosted() -> None: 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 "command -v python3" in workflow assert "command -v uv" in workflow - assert "uv sync --python python3" in workflow + assert "uv sync" in workflow + assert "uv sync --python python3" not in workflow def test_build_report_all_pass(): From ff697da6ea635c8eb790fb93b5982cc25961490d Mon Sep 17 00:00:00 2001 From: randoneering Date: Tue, 21 Apr 2026 03:15:43 +0000 Subject: [PATCH 22/23] uv at shell --- .github/workflows/integration-pg-matrix.yml | 1 + .github/workflows/managed-db-validate.yml | 1 + testing/test_seed_and_validate.py | 1 + 3 files changed, 3 insertions(+) diff --git a/.github/workflows/integration-pg-matrix.yml b/.github/workflows/integration-pg-matrix.yml index 5ca215b..089bd43 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: diff --git a/.github/workflows/managed-db-validate.yml b/.github/workflows/managed-db-validate.yml index a48e684..417a279 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 }} diff --git a/testing/test_seed_and_validate.py b/testing/test_seed_and_validate.py index d4fb1ce..7dc0487 100644 --- a/testing/test_seed_and_validate.py +++ b/testing/test_seed_and_validate.py @@ -136,6 +136,7 @@ def test_integration_workflows_use_runner_python_on_self_hosted() -> None: 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 "command -v uv" in workflow assert "uv sync" in workflow assert "uv sync --python python3" not in workflow From 396758d4bccff331ce3600fb421c8fea36384cd0 Mon Sep 17 00:00:00 2001 From: randoneering Date: Tue, 21 Apr 2026 03:23:02 +0000 Subject: [PATCH 23/23] changing env for nix runner --- .github/workflows/integration-pg-matrix.yml | 7 +++++++ .github/workflows/managed-db-validate.yml | 7 +++++++ testing/test_seed_and_validate.py | 3 +++ 3 files changed, 17 insertions(+) diff --git a/.github/workflows/integration-pg-matrix.yml b/.github/workflows/integration-pg-matrix.yml index 089bd43..7c21d3a 100644 --- a/.github/workflows/integration-pg-matrix.yml +++ b/.github/workflows/integration-pg-matrix.yml @@ -49,6 +49,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: Validate required PG env vars run: | missing=0 diff --git a/.github/workflows/managed-db-validate.yml b/.github/workflows/managed-db-validate.yml index 417a279..9ac523e 100644 --- a/.github/workflows/managed-db-validate.yml +++ b/.github/workflows/managed-db-validate.yml @@ -80,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 diff --git a/testing/test_seed_and_validate.py b/testing/test_seed_and_validate.py index 7dc0487..96d9ba9 100644 --- a/testing/test_seed_and_validate.py +++ b/testing/test_seed_and_validate.py @@ -137,7 +137,10 @@ def test_integration_workflows_use_runner_python_on_self_hosted() -> None: 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