diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d23d180..1a82787 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -43,8 +43,10 @@ **Has this code been deployed and tested on the following platforms?** - [ ] Amazon RDS for PostgreSQL -- [ ] Google Cloud SQL for PostgreSQL -- [ ] Azure Database for PostgreSQL +- [ ] Google Cloud SQL for PostgreSQL (currently unable to test) +- [ ] Azure Database for PostgreSQL (currently unable to test) +- [ ] Neon +- [ ] Supabase - [ ] Self-managed PostgreSQL **Platform-specific notes:** diff --git a/.gitignore b/.gitignore index 6f33c39..d5c37af 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,17 @@ terraform.tfstate terraform.tfstate.backup *.tfvars +*.tfvars.json !terraform.tfvars.example *.tfstate +*.tfstate.* *.tfstate.backup +crash.log +crash.*.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json # Sensitive files *.pem *.key @@ -14,13 +22,16 @@ credentials.json .pgpass pgpass # AI dirs -.claude .agent .copilot - -# AI Files +.claude +# AI files +AGENTS.md CLAUDE.md +# Local docs +docs/superpowers/plans/ + # Python __pycache__/ .pytest_cache/ diff --git a/README.md b/README.md index 9c7e43b..e3105b6 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ That's it! No configuration needed. Deploy as a user with the highest possible p - **Table Bloat** - Tables with >20% bloat affecting performance (tables >100MB) - **Missing Statistics** - Tables never analyzed, leaving the query planner without statistics -- **Duplicate Indexes** - Multiple indexes with identical or overlapping column sets +- **Duplicate Indexes** - Indexes with the same structure, including predicates and expressions - **Inactive Replication Slots** - Identifies replication slots that are inactive and can be removed if no longer needed - **Tables Larger Than 100GB** - Identifies tables that are larger than 100GB - **Tables With More Than 200 Columns** - List tables with more than 200 columns. You should probably look into those... @@ -121,8 +121,8 @@ That's it! No configuration needed. Deploy as a user with the highest possible p - **PostgreSQL Version** - Version information and configuration details - **Installed Extensions** - Lists installed extensions on the Server - **Server Uptime** - Server uptime since last restart -- **Log Directory** - Location of Log File(s). Results will vary for managed services like AWS RDS. (note: need access to AWS/Azure/GCP environments where I can test against!) -- **Log File Sizes** - The size of the log files. Again, this will vary for managed services. +- **Log Directory** - Current log directory when the platform exposes it +- **Log File Sizes** - Current log file sizes when the platform exposes them ## Usage Tips @@ -210,11 +210,14 @@ pgFirstAid is designed to be lightweight and safe to run on production systems: - A coverage guard ensures every `check_name` in `pgFirstAid.sql` is referenced by at least one pgTAP assertion. - 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. + ## Compatibility - **PostgreSQL 10+** - Supported, with active automated validation focused on PostgreSQL 15-18 - **PostgreSQL 9.x** - Most features work (minor syntax adjustments may be needed) - Works with PostgreSQL-compatible databases, including Amazon RDS, Aurora, Azure Database for PostgreSQL, GCP Cloud SQL, and self-hosted PostgreSQL +- Automated managed-database validation is active for AWS today. Azure and GCP support is best-effort until we can fund those test environments. ## Contributing diff --git a/pgFirstAid.sql b/pgFirstAid.sql index 2262943..6be916f 100644 --- a/pgFirstAid.sql +++ b/pgFirstAid.sql @@ -267,6 +267,38 @@ with pss as ( end; $$ language plpgsql; +-- Helper: returns formatted checkpoint stats compatible with PG15/16 (pg_stat_bgwriter) +-- and PG17+ (pg_stat_checkpointer, which replaced the checkpoint columns in pg_stat_bgwriter) +create or replace +function _pg_firstaid_checkpoint_stats() +returns text +language plpgsql +stable +as $$ +declare + v_timed bigint; + v_forced bigint; +begin + if current_setting('server_version_num')::int >= 170000 then + select num_timed, num_requested + into v_timed, v_forced + from pg_stat_checkpointer; + else + select checkpoints_timed, checkpoints_req + into v_timed, v_forced + from pg_stat_bgwriter; + end if; + + return 'timed: ' || v_timed::text || + ', forced: ' || v_forced::text || + ', forced ratio: ' || + case + when v_timed + v_forced = 0 then '0%' + else round(100.0 * v_forced / (v_timed + v_forced), 1)::text || '%' + end; +end; +$$; + create or replace function pg_firstAid() returns table ( @@ -1433,6 +1465,156 @@ order by 'Keep PostgreSQL updated and review configuration settings' as recommended_action, 'https://www.postgresql.org/docs/current/upgrading.html' as documentation_link, 5 as severity_order; +-- INFO: shared_buffers current value +insert into health_results +select + 'INFO' as severity, + 'System Health' as category, + 'shared_buffers Setting' as check_name, + 'System' as object_name, + 'Current value of shared_buffers. Recommended: ~25% of total system RAM for dedicated database servers.' as issue_description, + current_setting('shared_buffers') as current_value, + 'No action needed if already tuned. For dedicated DB servers with 8GB+ RAM, target 25% of total RAM. Changes require a PostgreSQL restart.' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS' as documentation_link, + 5 as severity_order; +-- HIGH: shared_buffers still at 128MB PostgreSQL default +insert into health_results +select + 'HIGH' as severity, + 'System Health' as category, + 'shared_buffers At Default' as check_name, + 'System' as object_name, + 'shared_buffers is set to the PostgreSQL default of 128MB. On any real workload this is almost certainly too low.' as issue_description, + current_setting('shared_buffers') as current_value, + 'Set shared_buffers to approximately 25% of total system RAM (e.g., 2GB on an 8GB server). Requires a PostgreSQL restart.' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS' as documentation_link, + 2 as severity_order +where pg_size_bytes(current_setting('shared_buffers')) = pg_size_bytes('128MB'); + +-- INFO: work_mem current value +insert into health_results +select + 'INFO' as severity, + 'System Health' as category, + 'work_mem Setting' as check_name, + 'System' as object_name, + 'Current value of work_mem. Allocated per sort/hash operation per session — multiply by max_connections and parallel workers to estimate peak memory consumption.' as issue_description, + current_setting('work_mem') || ' (max_connections: ' || current_setting('max_connections') || ')' as current_value, + 'For OLTP workloads, 16-32MB is a common starting point. Monitor pg_stat_statements for temp file spills to determine if higher is warranted. Use SET work_mem per-session for large one-off queries rather than setting globally.' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM' as documentation_link, + 5 as severity_order; + +-- MEDIUM: work_mem still at 4MB PostgreSQL default +insert into health_results +select + 'MEDIUM' as severity, + 'System Health' as category, + 'work_mem At Default' as check_name, + 'System' as object_name, + 'work_mem is set to the PostgreSQL default of 4MB. On modern hardware this often causes unnecessary sort and hash spills to disk.' as issue_description, + current_setting('work_mem') as current_value, + 'Consider raising work_mem to 16-32MB for OLTP workloads. Be aware that work_mem is allocated per operation per session — high concurrency multiplies total memory usage.' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM' as documentation_link, + 3 as severity_order +where pg_size_bytes(current_setting('work_mem')) = pg_size_bytes('4MB'); + +-- INFO: effective_cache_size current value +insert into health_results +select + 'INFO' as severity, + 'System Health' as category, + 'effective_cache_size Setting' as check_name, + 'System' as object_name, + 'Current value of effective_cache_size. Tells the query planner how much memory is available for disk caching. Does not allocate memory — purely advisory.' as issue_description, + current_setting('effective_cache_size') as current_value, + 'Set to ~50-75% of total system RAM (shared_buffers + expected OS page cache). Underestimates cause the planner to prefer nested loops over index scans.' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-EFFECTIVE-CACHE-SIZE' as documentation_link, + 5 as severity_order; + +-- INFO: maintenance_work_mem current value +insert into health_results +select + 'INFO' as severity, + 'System Health' as category, + 'maintenance_work_mem Setting' as check_name, + 'System' as object_name, + 'Current value of maintenance_work_mem. Used by VACUUM, CREATE INDEX, ALTER TABLE, and each autovacuum worker.' as issue_description, + current_setting('maintenance_work_mem') as current_value, + 'Consider 256MB-1GB on modern hardware. Higher values speed up index builds and autovacuum on large tables. Changes take effect immediately for new sessions.' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAINTENANCE-WORK-MEM' as documentation_link, + 5 as severity_order; + +-- INFO: Transaction ID wraparound risk per database +insert into health_results +select + 'INFO' as severity, + 'System Health' as category, + 'Transaction ID Wraparound Risk' as check_name, + datname as object_name, + 'Age of the oldest unfrozen transaction ID in this database. PostgreSQL must freeze XIDs before reaching ~2.1 billion to prevent data loss from wraparound.' as issue_description, + datname || ': XID age ' || trim(to_char(age(datfrozenxid), 'FM999,999,999,990')) || + ' (' || round(age(datfrozenxid)::numeric * 100 / 2000000000, 1)::text || + '% of wraparound window, ~' || + trim(to_char(greatest(2000000000::bigint - age(datfrozenxid)::bigint, 0), 'FM999,999,999,990')) || + ' remaining)' as current_value, + 'Run VACUUM FREEZE on databases approaching high XID age. Ensure autovacuum is enabled and not blocked. Monitor databases with age > 500,000,000.' as recommended_action, + 'https://www.postgresql.org/docs/current/routine-vacuuming.html#VACUUM-FOR-WRAPAROUND' as documentation_link, + 5 as severity_order +from + pg_database +where + datallowconn = true; + +-- INFO: Checkpoint statistics (PG15/16: pg_stat_bgwriter, PG17+: pg_stat_checkpointer) +insert into health_results +select + 'INFO' as severity, + 'System Health' as category, + 'Checkpoint Stats' as check_name, + 'System' as object_name, + 'Checkpoint activity since stats last reset. Forced checkpoints occur when WAL fills up before the scheduled interval — high ratios suggest max_wal_size may be too small. PG15/16 reads from pg_stat_bgwriter; PG17+ reads from pg_stat_checkpointer.' as issue_description, + _pg_firstaid_checkpoint_stats() as current_value, + 'If forced checkpoints are consistently above 50% of total, consider increasing max_wal_size. Reset stats with: SELECT pg_stat_reset_shared(''' || + case + when current_setting('server_version_num')::int >= 170000 then 'checkpointer' + else 'bgwriter' + end || + ''').' as recommended_action, + 'https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-BGWRITER-VIEW' as documentation_link, + 5 as severity_order; + +-- INFO: Server role (primary vs standby) +insert into health_results +select + 'INFO' as severity, + 'System Info' as category, + 'Server Role' as check_name, + 'System' as object_name, + 'Whether this server is operating as a primary or standby replica. Context for interpreting other checks — some checks are only relevant on standbys.' as issue_description, + case + when pg_is_in_recovery() then 'Standby (replica)' + else 'Primary' + end as current_value, + 'No action needed — informational.' as recommended_action, + 'https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-RECOVERY-INFO-TABLE' as documentation_link, + 5 as severity_order; + +-- INFO: Connection utilization +insert into health_results +select + 'INFO' as severity, + 'System Health' as category, + 'Connection Utilization' as check_name, + 'System' as object_name, + 'Current connection usage as a percentage of max_connections. Includes all connection states, not just active queries.' as issue_description, + count(*)::text || ' total / ' || current_setting('max_connections') || ' max (' || + round(100.0 * count(*) / current_setting('max_connections')::int, 1)::text || '% used)' as current_value, + 'If consistently above 80%, consider a connection pooler such as PgBouncer. Reserve headroom for superuser connections (superuser_reserved_connections).' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-connection.html' as documentation_link, + 5 as severity_order +from + pg_stat_activity; + -- INFO: Installed Extensions insert into diff --git a/testing/.flox/.gitattributes b/testing/.flox/.gitattributes new file mode 100644 index 0000000..bb5491e --- /dev/null +++ b/testing/.flox/.gitattributes @@ -0,0 +1 @@ +env/manifest.lock linguist-generated=true linguist-language=JSON diff --git a/testing/.flox/.gitignore b/testing/.flox/.gitignore new file mode 100644 index 0000000..8d21186 --- /dev/null +++ b/testing/.flox/.gitignore @@ -0,0 +1,5 @@ +run/ +cache/ +lib/ +log/ +!env/ diff --git a/testing/.flox/env.json b/testing/.flox/env.json new file mode 100644 index 0000000..eaa4626 --- /dev/null +++ b/testing/.flox/env.json @@ -0,0 +1,4 @@ +{ + "name": "pgFirstAid", + "version": 1 +} diff --git a/testing/.flox/env/manifest.lock b/testing/.flox/env/manifest.lock new file mode 100644 index 0000000..592c834 --- /dev/null +++ b/testing/.flox/env/manifest.lock @@ -0,0 +1,680 @@ +{ + "lockfile-version": 1, + "manifest": { + "schema-version": "1.10.0", + "install": { + "pip": { + "pkg-path": "python312Packages.pip" + }, + "psycopg2": { + "pkg-path": "python312Packages.psycopg2" + }, + "pytest": { + "pkg-path": "python312Packages.pytest" + }, + "python312Full": { + "pkg-path": "python312Full" + }, + "uv": { + "pkg-path": "uv" + } + }, + "hook": {}, + "profile": {}, + "options": {} + }, + "packages": [ + { + "attr_path": "python312Packages.pip", + "broken": false, + "derivation": "/nix/store/6c3vk1w26pz96c6jphzpn9yf0ik301y8-python3.12-pip-25.0.1.drv", + "description": "PyPA recommended tool for installing Python packages", + "install_id": "pip", + "license": "[ MIT ]", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "python3.12-pip-25.0.1", + "pname": "pip", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:26:18.931395Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "25.0.1", + "outputs_to_install": [ + "man", + "out" + ], + "outputs": { + "dist": "/nix/store/hj7j2kzxm8s04qj271s8yvdwlcrbr74d-python3.12-pip-25.0.1-dist", + "man": "/nix/store/nyajlkrlkl85d8m1vpnv7axvskphica3-python3.12-pip-25.0.1-man", + "out": "/nix/store/1r9wk052v005r2fd2awaw47ryqrb045n-python3.12-pip-25.0.1" + }, + "system": "aarch64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "python312Packages.pip", + "broken": false, + "derivation": "/nix/store/biv5zjq9fxr7vvh5gynjikbvsb5xzzj1-python3.12-pip-25.0.1.drv", + "description": "PyPA recommended tool for installing Python packages", + "install_id": "pip", + "license": "[ MIT ]", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "python3.12-pip-25.0.1", + "pname": "pip", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:35:05.188700Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "25.0.1", + "outputs_to_install": [ + "man", + "out" + ], + "outputs": { + "dist": "/nix/store/m7h2qkisxfq784wx69s6qzyhqkgiqf7a-python3.12-pip-25.0.1-dist", + "man": "/nix/store/xham2r3yqfvz512mp8wqz9mi78x8a546-python3.12-pip-25.0.1-man", + "out": "/nix/store/y3jnqnyni83z59ca9fd1sfn1llvcxahw-python3.12-pip-25.0.1" + }, + "system": "aarch64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "python312Packages.pip", + "broken": false, + "derivation": "/nix/store/jhygj5afchyy0l41mpzdppjfvp59x785-python3.12-pip-25.0.1.drv", + "description": "PyPA recommended tool for installing Python packages", + "install_id": "pip", + "license": "[ MIT ]", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "python3.12-pip-25.0.1", + "pname": "pip", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:44:10.811511Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "25.0.1", + "outputs_to_install": [ + "man", + "out" + ], + "outputs": { + "dist": "/nix/store/0dhn33kvy534p6290di1akrr1ry0k9yg-python3.12-pip-25.0.1-dist", + "man": "/nix/store/vpklfd7g7zlg16kf6z10fhaxz6vc10qa-python3.12-pip-25.0.1-man", + "out": "/nix/store/hriq8hl1ckrl1mrvkx281k4n2y7wq1ga-python3.12-pip-25.0.1" + }, + "system": "x86_64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "python312Packages.pip", + "broken": false, + "derivation": "/nix/store/1yixssx3y6nkh99xskx0lk7lzaq7s3j2-python3.12-pip-25.0.1.drv", + "description": "PyPA recommended tool for installing Python packages", + "install_id": "pip", + "license": "[ MIT ]", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "python3.12-pip-25.0.1", + "pname": "pip", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:52:05.076504Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "25.0.1", + "outputs_to_install": [ + "man", + "out" + ], + "outputs": { + "dist": "/nix/store/ybqryzn42pga752yf9agl0m97p0rvglc-python3.12-pip-25.0.1-dist", + "man": "/nix/store/492i2sc237lp36i7y0hn6da25fsq5flf-python3.12-pip-25.0.1-man", + "out": "/nix/store/jcxmz9bq3ds1x6f0l8lkp8s0nsh1n9h6-python3.12-pip-25.0.1" + }, + "system": "x86_64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "python312Packages.psycopg2", + "broken": false, + "derivation": "/nix/store/90q9xmchbvmrscp02q9ixbgaiyq1p8mv-python3.12-psycopg2-2.9.10.drv", + "description": "PostgreSQL database adapter for the Python programming language", + "install_id": "psycopg2", + "license": "[ LGPL-3.0-or-later, ZPL-2.0 ]", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "python3.12-psycopg2-2.9.10", + "pname": "psycopg2", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:26:19.185249Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "python3.12-psycopg2-2.9.10", + "outputs_to_install": [ + "out" + ], + "outputs": { + "dist": "/nix/store/4f7ixm4hczh4jvkp0y84am8ajw49v5c8-python3.12-psycopg2-2.9.10-dist", + "doc": "/nix/store/vbv3cy5dhsg0bha0d1vlvbqf33g52ppr-python3.12-psycopg2-2.9.10-doc", + "out": "/nix/store/w6y5kl7j705a7cb8ry4giisgd0298xh1-python3.12-psycopg2-2.9.10" + }, + "system": "aarch64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "python312Packages.psycopg2", + "broken": false, + "derivation": "/nix/store/p44l3nm3k7ygp02zhaiw1xh14js321vc-python3.12-psycopg2-2.9.10.drv", + "description": "PostgreSQL database adapter for the Python programming language", + "install_id": "psycopg2", + "license": "[ LGPL-3.0-or-later, ZPL-2.0 ]", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "python3.12-psycopg2-2.9.10", + "pname": "psycopg2", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:35:05.651141Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "python3.12-psycopg2-2.9.10", + "outputs_to_install": [ + "out" + ], + "outputs": { + "dist": "/nix/store/lz5fqp8qc1vlf0sazsj7bkl7p85kmv84-python3.12-psycopg2-2.9.10-dist", + "doc": "/nix/store/sf26mddkwxyqcfaz6y0yrpvd50ijgrs1-python3.12-psycopg2-2.9.10-doc", + "out": "/nix/store/l8vklqnplarlf7b6vsdvy7iq4wpkmqm2-python3.12-psycopg2-2.9.10" + }, + "system": "aarch64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "python312Packages.psycopg2", + "broken": false, + "derivation": "/nix/store/5gdfasp58xjh2wvjk9w414z79pmzy7qx-python3.12-psycopg2-2.9.10.drv", + "description": "PostgreSQL database adapter for the Python programming language", + "install_id": "psycopg2", + "license": "[ LGPL-3.0-or-later, ZPL-2.0 ]", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "python3.12-psycopg2-2.9.10", + "pname": "psycopg2", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:44:11.065286Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "python3.12-psycopg2-2.9.10", + "outputs_to_install": [ + "out" + ], + "outputs": { + "dist": "/nix/store/1vz0mc2dzf8m6lv2flpzng6zw8yc4rdm-python3.12-psycopg2-2.9.10-dist", + "doc": "/nix/store/5rgy6355rg0qfxh9gmgdlp9rm63chk7j-python3.12-psycopg2-2.9.10-doc", + "out": "/nix/store/50cks2kladwn6glaqxp98i9j7760y9x6-python3.12-psycopg2-2.9.10" + }, + "system": "x86_64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "python312Packages.psycopg2", + "broken": false, + "derivation": "/nix/store/1igd397cnlj9z0ksn381sqghrmhglp49-python3.12-psycopg2-2.9.10.drv", + "description": "PostgreSQL database adapter for the Python programming language", + "install_id": "psycopg2", + "license": "[ LGPL-3.0-or-later, ZPL-2.0 ]", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "python3.12-psycopg2-2.9.10", + "pname": "psycopg2", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:52:05.470710Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "python3.12-psycopg2-2.9.10", + "outputs_to_install": [ + "out" + ], + "outputs": { + "dist": "/nix/store/jk2gg3r12pdq9nfcnqqjklrdis1il2yx-python3.12-psycopg2-2.9.10-dist", + "doc": "/nix/store/q69nfvcl9y6j0s57qfz4xlp842kiqlk3-python3.12-psycopg2-2.9.10-doc", + "out": "/nix/store/g4hf7rhbm43qza92v3ln46zhdn6gx006-python3.12-psycopg2-2.9.10" + }, + "system": "x86_64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "python312Packages.pytest", + "broken": false, + "derivation": "/nix/store/4hjqzk5sakb5xfmvzjg671kgci1rknck-python3.12-pytest-8.4.1.drv", + "description": "Framework for writing tests", + "install_id": "pytest", + "license": "MIT", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "python3.12-pytest-8.4.1", + "pname": "pytest", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:26:20.302363Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "8.4.1", + "outputs_to_install": [ + "out" + ], + "outputs": { + "dist": "/nix/store/9n5scdgf4ri1mxk90zabpy9jv88h27z4-python3.12-pytest-8.4.1-dist", + "out": "/nix/store/5imkz6mw3936s8wawn71i1iinq4zxh4l-python3.12-pytest-8.4.1", + "testout": "/nix/store/vyp6788wds35pp2rq01wl4dskhs3gcdj-python3.12-pytest-8.4.1-testout" + }, + "system": "aarch64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "python312Packages.pytest", + "broken": false, + "derivation": "/nix/store/i6nz6zb54lji5i1ivnky4c1ya3m048s6-python3.12-pytest-8.4.1.drv", + "description": "Framework for writing tests", + "install_id": "pytest", + "license": "MIT", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "python3.12-pytest-8.4.1", + "pname": "pytest", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:35:07.574632Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "8.4.1", + "outputs_to_install": [ + "out" + ], + "outputs": { + "dist": "/nix/store/fk10nyiwsaxpqyi9wf13i3156r10awb1-python3.12-pytest-8.4.1-dist", + "out": "/nix/store/hqsrkwskfh8q9zc6l9bggy7zahfa9y6w-python3.12-pytest-8.4.1", + "testout": "/nix/store/dahn4x0ji0rl95c617bz6rjhd6sjiyrb-python3.12-pytest-8.4.1-testout" + }, + "system": "aarch64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "python312Packages.pytest", + "broken": false, + "derivation": "/nix/store/bf5k292in4zd6wp2iib3xgz3jdrga9fz-python3.12-pytest-8.4.1.drv", + "description": "Framework for writing tests", + "install_id": "pytest", + "license": "MIT", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "python3.12-pytest-8.4.1", + "pname": "pytest", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:44:12.173575Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "8.4.1", + "outputs_to_install": [ + "out" + ], + "outputs": { + "dist": "/nix/store/wla07j5gfrvzyv4s53dw509c9c85fk76-python3.12-pytest-8.4.1-dist", + "out": "/nix/store/nc05alv8nnydj0rc2pc7k6y7y03jcvxk-python3.12-pytest-8.4.1", + "testout": "/nix/store/81n7kzi6zglzhl4hslvd7bxixl9mpc30-python3.12-pytest-8.4.1-testout" + }, + "system": "x86_64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "python312Packages.pytest", + "broken": false, + "derivation": "/nix/store/xsjw0p8899g5j7y6bhm8pgds58c92ipw-python3.12-pytest-8.4.1.drv", + "description": "Framework for writing tests", + "install_id": "pytest", + "license": "MIT", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "python3.12-pytest-8.4.1", + "pname": "pytest", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:52:07.254188Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "8.4.1", + "outputs_to_install": [ + "out" + ], + "outputs": { + "dist": "/nix/store/2q6nfg0df9p5fr44s9m30h7l6pm2yb94-python3.12-pytest-8.4.1-dist", + "out": "/nix/store/ggk45c8i53zyrs8w9vy7l4bqmmsjjjlc-python3.12-pytest-8.4.1", + "testout": "/nix/store/21giixazd8iyl84w6n097gw5rflsz0rc-python3.12-pytest-8.4.1-testout" + }, + "system": "x86_64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "python312Full", + "broken": false, + "derivation": "/nix/store/x6c9qj50npdybsz6hp59xmki89jafj6d-python3-3.12.11.drv", + "description": "High-level dynamically-typed programming language", + "install_id": "python312Full", + "license": "Python-2.0", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "python3-3.12.11", + "pname": "python312Full", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:26:13.141205Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "python3-3.12.11", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/03p3nrkwhpzjr55dfcxhjq1hd3cg4pwy-python3-3.12.11" + }, + "system": "aarch64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "python312Full", + "broken": false, + "derivation": "/nix/store/5siqr82ark2r66lhq3wxymgij7sl3s68-python3-3.12.11.drv", + "description": "High-level dynamically-typed programming language", + "install_id": "python312Full", + "license": "Python-2.0", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "python3-3.12.11", + "pname": "python312Full", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:34:54.887531Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "python3-3.12.11", + "outputs_to_install": [ + "out" + ], + "outputs": { + "debug": "/nix/store/q70n8zbzknvnaqp4l0bwx5y3kazwwnj2-python3-3.12.11-debug", + "out": "/nix/store/jij9z6imnjl6ns2x3fr0cgfssy3j273d-python3-3.12.11" + }, + "system": "aarch64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "python312Full", + "broken": false, + "derivation": "/nix/store/axj8r2hnwjj941y08qn3jq5rrxcvczvl-python3-3.12.11.drv", + "description": "High-level dynamically-typed programming language", + "install_id": "python312Full", + "license": "Python-2.0", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "python3-3.12.11", + "pname": "python312Full", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:44:04.927920Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "python3-3.12.11", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/y3z25yr49hyabwjgjxn45v8i97wrgi25-python3-3.12.11" + }, + "system": "x86_64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "python312Full", + "broken": false, + "derivation": "/nix/store/hhjkb1fmbi3h9kn1jpqjd7gmq2jbfrw3-python3-3.12.11.drv", + "description": "High-level dynamically-typed programming language", + "install_id": "python312Full", + "license": "Python-2.0", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "python3-3.12.11", + "pname": "python312Full", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:51:55.789109Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "python3-3.12.11", + "outputs_to_install": [ + "out" + ], + "outputs": { + "debug": "/nix/store/xv76sfvnw9zi996yxbz8jyk6dj0ijk29-python3-3.12.11-debug", + "out": "/nix/store/a4vblf4lkfvdrysnzh7ir9m61irk70d0-python3-3.12.11" + }, + "system": "x86_64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "uv", + "broken": false, + "derivation": "/nix/store/g2kfc67p7fg7q5zpri6432d36ak1bsj3-uv-0.8.6.drv", + "description": "Extremely fast Python package installer and resolver, written in Rust", + "install_id": "uv", + "license": "[ Apache-2.0, MIT ]", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "uv-0.8.6", + "pname": "uv", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:27:20.423821Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "0.8.6", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/hxazvw40v90nic1yjx23cdkj6hqqlywa-uv-0.8.6" + }, + "system": "aarch64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "uv", + "broken": false, + "derivation": "/nix/store/5m6laj37dkg7jpxmr69dnb3q2m422d1a-uv-0.8.6.drv", + "description": "Extremely fast Python package installer and resolver, written in Rust", + "install_id": "uv", + "license": "[ Apache-2.0, MIT ]", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "uv-0.8.6", + "pname": "uv", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:36:40.649674Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "0.8.6", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/li6y1v2mjj30fd5prwkawidq5lcn9ja9-uv-0.8.6" + }, + "system": "aarch64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "uv", + "broken": false, + "derivation": "/nix/store/324hhmwqzqphn6vq37i4zkv3glamr2vg-uv-0.8.6.drv", + "description": "Extremely fast Python package installer and resolver, written in Rust", + "install_id": "uv", + "license": "[ Apache-2.0, MIT ]", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "uv-0.8.6", + "pname": "uv", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:45:11.571680Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "0.8.6", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/l37rdvvgc46jpydmyrwlxfhrslbfcnpm-uv-0.8.6" + }, + "system": "x86_64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "uv", + "broken": false, + "derivation": "/nix/store/fsv3g2p4y7c04678a0bpk6yparh2ysb7-uv-0.8.6.drv", + "description": "Extremely fast Python package installer and resolver, written in Rust", + "install_id": "uv", + "license": "[ Apache-2.0, MIT ]", + "locked_url": "https://github.com/flox/nixpkgs?rev=8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "name": "uv-0.8.6", + "pname": "uv", + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev_count": 852432, + "rev_date": "2025-08-27T03:49:43Z", + "scrape_date": "2025-08-28T03:53:31.236928Z", + "stabilities": [ + "unstable", + "staging", + "stable" + ], + "unfree": false, + "version": "0.8.6", + "outputs_to_install": [ + "out", + "out", + "out" + ], + "outputs": { + "out": "/nix/store/hxvcjq4a053l4gm8asl4ynjyhlz2azib-uv-0.8.6" + }, + "system": "x86_64-linux", + "group": "toplevel", + "priority": 5 + } + ] +} diff --git a/testing/.flox/env/manifest.toml b/testing/.flox/env/manifest.toml new file mode 100644 index 0000000..a83fe09 --- /dev/null +++ b/testing/.flox/env/manifest.toml @@ -0,0 +1,100 @@ +schema-version = "1.10.0" + + +## Install Packages -------------------------------------------------- +## $ flox install gum <- puts a package in [install] section below +## $ flox search gum <- search for a package +## $ flox show gum <- show all versions of a package +## ------------------------------------------------------------------- +[install] +pip.pkg-path = "python312Packages.pip" +python312Full.pkg-path = "python312Full" +uv.pkg-path = "uv" +psycopg2.pkg-path = "python312Packages.psycopg2" +pytest.pkg-path = "python312Packages.pytest" +# gum.pkg-path = "gum" +# gum.version = "^0.14.5" + + +## Environment Variables --------------------------------------------- +## ... available for use in the activated environment +## as well as [hook], [profile] scripts and [services] below. +## ------------------------------------------------------------------- +[vars] +# INTRO_MESSAGE = "It's gettin' Flox in here" + + +## Activation Hook --------------------------------------------------- +## ... run by _bash_ shell when you run 'flox activate'. +## ------------------------------------------------------------------- +[hook] +# on-activate = ''' +# # -> Set variables, create files and directories +# # -> Perform initialization steps, e.g. create a python venv +# # -> Useful environment variables: +# # - FLOX_ENV_PROJECT=/home/user/example +# # - FLOX_ENV=/home/user/example/.flox/run +# # - FLOX_ENV_CACHE=/home/user/example/.flox/cache +# ''' + + +## Profile script ---------------------------------------------------- +## ... sourced by _your shell_ when you run 'flox activate'. +## ------------------------------------------------------------------- +[profile] +# common = ''' +# gum style \ +# --foreground 212 --border-foreground 212 --border double \ +# --align center --width 50 --margin "1 2" --padding "2 4" \ +# $INTRO_MESSAGE +# ''' +## Shell-specific customizations such as setting aliases go here: +# bash = ... +# zsh = ... +# fish = ... + + +## Services --------------------------------------------------------- +## $ flox services start <- Starts all services +## $ flox services status <- Status of running services +## $ flox activate --start-services <- Activates & starts all +## ------------------------------------------------------------------ +[services] +# myservice.command = "python3 -m http.server" + + +## Include ---------------------------------------------------------- +## ... environments to create a composed environment +## ------------------------------------------------------------------ +[include] +# environments = [ +# { dir = "../common" } +# ] + + +## Build and publish your own packages ------------------------------ +## $ flox build +## $ flox publish +## ------------------------------------------------------------------ +[build] +# [build.myproject] +# description = "The coolest project ever" +# version = "0.0.1" +# command = """ +# mkdir -p $out/bin +# cargo build --release +# cp target/release/myproject $out/bin/myproject +# """ + + +## Other Environment Options ----------------------------------------- +[options] +# Systems that environment is compatible with +# systems = [ +# "aarch64-darwin", +# "aarch64-linux", +# "x86_64-darwin", +# "x86_64-linux", +# ] +# Uncomment to disable CUDA detection. +# cuda-detection = false diff --git a/testing/.flox/pip.ini b/testing/.flox/pip.ini new file mode 100644 index 0000000..64f805d --- /dev/null +++ b/testing/.flox/pip.ini @@ -0,0 +1,2 @@ + [global] + require-virtualenv = true diff --git a/testing/gcp/deploy/pg15/main.tf b/testing/gcp/deploy/pg15/main.tf index e765a22..13d9bb3 100644 --- a/testing/gcp/deploy/pg15/main.tf +++ b/testing/gcp/deploy/pg15/main.tf @@ -11,7 +11,7 @@ module "postgres" { region = local.region database_name = local.database_name db_user = local.db_user - authorized_networks = var.authorized_networks + personal_ip = var.personal_ip db_password = var.db_password } diff --git a/testing/gcp/deploy/pg15/vars.tf b/testing/gcp/deploy/pg15/vars.tf index 1c4a1c4..dd13297 100644 --- a/testing/gcp/deploy/pg15/vars.tf +++ b/testing/gcp/deploy/pg15/vars.tf @@ -1,9 +1,6 @@ -variable "authorized_networks" { - description = "Authorized networks for Cloud SQL" - type = list(object({ - name = string - value = string - })) +variable "personal_ip" { + description = "Personal IP to allow connections from" + type = string } variable "db_password" { diff --git a/testing/gcp/deploy/pg16/main.tf b/testing/gcp/deploy/pg16/main.tf index e765a22..13d9bb3 100644 --- a/testing/gcp/deploy/pg16/main.tf +++ b/testing/gcp/deploy/pg16/main.tf @@ -11,7 +11,7 @@ module "postgres" { region = local.region database_name = local.database_name db_user = local.db_user - authorized_networks = var.authorized_networks + personal_ip = var.personal_ip db_password = var.db_password } diff --git a/testing/gcp/deploy/pg16/vars.tf b/testing/gcp/deploy/pg16/vars.tf index 1c4a1c4..dd13297 100644 --- a/testing/gcp/deploy/pg16/vars.tf +++ b/testing/gcp/deploy/pg16/vars.tf @@ -1,9 +1,6 @@ -variable "authorized_networks" { - description = "Authorized networks for Cloud SQL" - type = list(object({ - name = string - value = string - })) +variable "personal_ip" { + description = "Personal IP to allow connections from" + type = string } variable "db_password" { diff --git a/testing/gcp/deploy/pg17/main.tf b/testing/gcp/deploy/pg17/main.tf index e765a22..13d9bb3 100644 --- a/testing/gcp/deploy/pg17/main.tf +++ b/testing/gcp/deploy/pg17/main.tf @@ -11,7 +11,7 @@ module "postgres" { region = local.region database_name = local.database_name db_user = local.db_user - authorized_networks = var.authorized_networks + personal_ip = var.personal_ip db_password = var.db_password } diff --git a/testing/gcp/deploy/pg17/vars.tf b/testing/gcp/deploy/pg17/vars.tf index 1c4a1c4..dd13297 100644 --- a/testing/gcp/deploy/pg17/vars.tf +++ b/testing/gcp/deploy/pg17/vars.tf @@ -1,9 +1,6 @@ -variable "authorized_networks" { - description = "Authorized networks for Cloud SQL" - type = list(object({ - name = string - value = string - })) +variable "personal_ip" { + description = "Personal IP to allow connections from" + type = string } variable "db_password" { diff --git a/testing/integration/pyproject.toml b/testing/integration/pyproject.toml index 8296390..51e2202 100644 --- a/testing/integration/pyproject.toml +++ b/testing/integration/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "Python integration harness for pgFirstAid health checks" requires-python = ">=3.11" dependencies = [ - "psycopg[binary]>=3.2.0", + "psycopg2-binary>=2.9.11", "pytest>=8.3.0", ] diff --git a/testing/integration/src/pgfirstaid_pytest/config.py b/testing/integration/src/pgfirstaid_pytest/config.py index 7c7cbbf..6cf4cef 100644 --- a/testing/integration/src/pgfirstaid_pytest/config.py +++ b/testing/integration/src/pgfirstaid_pytest/config.py @@ -2,6 +2,9 @@ import os +_REQUIRED_ENV_VARS = ("PGHOST", "PGPORT", "PGUSER", "PGDATABASE") + + @dataclass(frozen=True) class TestConfig: host: str @@ -14,14 +17,27 @@ class TestConfig: active_conn_sleep_seconds: int wait_timeout_seconds: int + @classmethod + def missing_env_vars(cls) -> tuple[str, ...]: + return tuple(name for name in _REQUIRED_ENV_VARS if not os.getenv(name)) + @classmethod def from_env(cls) -> "TestConfig": + missing = cls.missing_env_vars() + if missing: + missing_list = ", ".join(missing) + raise ValueError( + "Missing required PostgreSQL environment variables: " + f"{missing_list}. Set the standard PG* connection variables before " + "running integration tests." + ) + return cls( - host=os.getenv("PGHOST", "localhost"), - port=int(os.getenv("PGPORT", "5432")), - user=os.getenv("PGUSER", "postgres"), + host=os.environ["PGHOST"], + port=int(os.environ["PGPORT"]), + user=os.environ["PGUSER"], password=os.getenv("PGPASSWORD"), - database=os.getenv("PGDATABASE", "postgres"), + database=os.environ["PGDATABASE"], sslmode=os.getenv("PGSSLMODE"), active_conn_target=int(os.getenv("PGFA_TEST_ACTIVE_CONN_TARGET", "52")), active_conn_sleep_seconds=int( diff --git a/testing/integration/src/pgfirstaid_pytest/db.py b/testing/integration/src/pgfirstaid_pytest/db.py index 9581c67..23976b5 100644 --- a/testing/integration/src/pgfirstaid_pytest/db.py +++ b/testing/integration/src/pgfirstaid_pytest/db.py @@ -1,24 +1,26 @@ +from contextlib import contextmanager from pathlib import Path import time -from typing import Any +from typing import Any, Iterator -import psycopg +import psycopg2 +from psycopg2.extensions import connection as PgConnection from .config import TestConfig +@contextmanager def connect( config: TestConfig, *, autocommit: bool = True, application_name: str | None = None, -) -> psycopg.Connection[Any]: +) -> Iterator[PgConnection]: kwargs: dict[str, Any] = { "host": config.host, "port": config.port, "user": config.user, "dbname": config.database, - "autocommit": autocommit, } if config.password is not None: kwargs["password"] = config.password @@ -26,17 +28,23 @@ def connect( kwargs["sslmode"] = config.sslmode if application_name is not None: kwargs["application_name"] = application_name - return psycopg.connect(**kwargs) + conn = psycopg2.connect(**kwargs) + conn.autocommit = autocommit + try: + yield conn + finally: + conn.close() -def execute_sql_file(conn: psycopg.Connection[Any], file_path: Path) -> None: + +def execute_sql_file(conn: PgConnection, file_path: Path) -> None: sql_text = file_path.read_text(encoding="utf-8") with conn.cursor() as cur: cur.execute(sql_text) def wait_for_sql_true( - conn: psycopg.Connection[Any], + conn: PgConnection, sql: str, params: tuple[Any, ...] | None = None, *, diff --git a/testing/integration/tests/conftest.py b/testing/integration/tests/conftest.py index 0403477..363681d 100644 --- a/testing/integration/tests/conftest.py +++ b/testing/integration/tests/conftest.py @@ -1,8 +1,10 @@ from pathlib import Path import sys from typing import Iterator + import pytest -import psycopg +import psycopg2 +from psycopg2.extensions import connection as PgConnection sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) @@ -23,6 +25,15 @@ def _teardown_sql_path() -> Path: @pytest.fixture(scope="session") def config() -> TestConfig: + missing = TestConfig.missing_env_vars() + if missing: + missing_list = ", ".join(missing) + pytest.skip( + "Missing required PostgreSQL environment variables: " + f"{missing_list}. Set the standard PG* connection variables before " + "running integration tests." + ) + return TestConfig.from_env() @@ -39,13 +50,13 @@ def prepared_database(config: TestConfig) -> Iterator[None]: def db_conn( config: TestConfig, prepared_database: None, -) -> Iterator[psycopg.Connection[tuple[object, ...]]]: +) -> Iterator[PgConnection]: with connect(config, autocommit=True) as conn: yield conn @pytest.fixture -def test_schema(db_conn: psycopg.Connection[tuple[object, ...]]) -> Iterator[str]: +def test_schema(db_conn: PgConnection) -> Iterator[str]: schema_name = "pgfirstaid_pytest" with db_conn.cursor() as cur: cur.execute(f"CREATE SCHEMA IF NOT EXISTS {schema_name}") diff --git a/testing/integration/tests/integration/test_health_checks.py b/testing/integration/tests/integration/test_health_checks.py index c2a129f..21cbe2e 100644 --- a/testing/integration/tests/integration/test_health_checks.py +++ b/testing/integration/tests/integration/test_health_checks.py @@ -2,7 +2,8 @@ import threading import time -import psycopg +import psycopg2 +from psycopg2.extensions import connection as PgConnection import pytest from pgfirstaid_pytest import TestConfig as PgConfig @@ -27,13 +28,13 @@ def _start_active_connection( with conn.cursor() as cur: cur.execute("SELECT pg_sleep(%s)", (sleep_seconds,)) return True - except psycopg.Error: + except psycopg2.Error: return False @pytest.mark.integration def test_outdated_statistics_detected( - db_conn: psycopg.Connection[tuple[object, ...]], + db_conn: PgConnection, test_schema: str, config: PgConfig, ) -> None: @@ -106,7 +107,7 @@ def test_outdated_statistics_detected( @pytest.mark.integration @pytest.mark.slow def test_high_connection_count_detected( - db_conn: psycopg.Connection[tuple[object, ...]], + db_conn: PgConnection, config: PgConfig, ) -> None: start_event = threading.Event() diff --git a/testing/integration/tests/integration/test_pgtap_suite.py b/testing/integration/tests/integration/test_pgtap_suite.py index 4fa2b09..0446391 100644 --- a/testing/integration/tests/integration/test_pgtap_suite.py +++ b/testing/integration/tests/integration/test_pgtap_suite.py @@ -5,7 +5,7 @@ import re import subprocess -import psycopg +from psycopg2.extensions import connection as PgConnection import pytest from pgfirstaid_pytest import TestConfig as PgConfig @@ -20,6 +20,14 @@ def _pgtap_dir() -> Path: return _repo_root() / "testing" / "pgTAP" +def _pgtap_setup_sql() -> Path: + return _pgtap_dir() / "00_setup.sql" + + +def _pgtap_teardown_sql() -> Path: + return _pgtap_dir() / "99_teardown.sql" + + def _pgtap_test_files() -> list[Path]: return sorted(_pgtap_dir().glob("0[1-9]_*.sql")) @@ -69,7 +77,7 @@ def _tap_failures(output: str) -> list[str]: def test_pgtap_sql_file_passes( config: PgConfig, prepared_database: None, - db_conn: psycopg.Connection[tuple[object, ...]], + db_conn: PgConnection, pgtap_file: Path, ) -> None: _ = prepared_database @@ -88,6 +96,22 @@ def test_pgtap_sql_file_passes( ) +@pytest.mark.integration +def test_session_teardown_handles_installed_objects(db_conn: PgConnection) -> None: + # The shared teardown runs at session end, after pg_firstAid() and v_pgfirstaid + # have been installed for the integration suite. + execute_sql_file(db_conn, _pgtap_setup_sql()) + execute_sql_file(db_conn, _repo_root() / "pgFirstAid.sql") + execute_sql_file(db_conn, _default_view_sql()) + + try: + execute_sql_file(db_conn, _pgtap_teardown_sql()) + finally: + execute_sql_file(db_conn, _pgtap_setup_sql()) + execute_sql_file(db_conn, _repo_root() / "pgFirstAid.sql") + execute_sql_file(db_conn, _default_view_sql()) + + def _extract_check_names_from_source(sql_text: str) -> set[str]: return set( re.findall(r"'([^']+)'\s+as\s+check_name", sql_text, flags=re.IGNORECASE) @@ -98,6 +122,21 @@ def _extract_check_names_from_pgtap(sql_text: str) -> set[str]: return set(re.findall(r"check_name\s*=\s*'([^']+)'", sql_text, flags=re.IGNORECASE)) +def _extract_function_body(sql_text: str, func_name: str) -> str | None: + # Match the dollar-quoted body of a named PL/pgSQL function. + # Use [$] character classes for literal $ because \$ loses the backslash + # in Python raw strings when $ is not a recognised escape sequence. + match = re.search( + rf"function\s+{re.escape(func_name)}\b[^$]*[$][$](.*?)[$][$]", + sql_text, + flags=re.IGNORECASE | re.DOTALL, + ) + if match is None: + return None + # Normalise whitespace so trivial formatting differences don't cause false diffs. + return "\n".join(line.strip() for line in match.group(1).strip().splitlines()) + + def _view_sql_files() -> list[Path]: root = _repo_root() all_views = [root / "view_pgFirstAid.sql", root / "view_pgFirstAid_managed.sql"] @@ -148,10 +187,42 @@ def test_both_view_sql_files_cover_all_health_checks() -> None: ) +@pytest.mark.integration +def test_checkpoint_stats_helper_body_matches_across_install_scripts() -> None: + # _pg_firstaid_checkpoint_stats() is duplicated in all three install scripts so + # they each remain standalone. This test catches accidental divergence. + root = _repo_root() + scripts = [ + root / "pgFirstAid.sql", + root / "view_pgFirstAid.sql", + root / "view_pgFirstAid_managed.sql", + ] + + bodies: dict[str, str] = {} + for script in scripts: + body = _extract_function_body( + script.read_text(encoding="utf-8"), + "_pg_firstaid_checkpoint_stats", + ) + assert body is not None, ( + f"_pg_firstaid_checkpoint_stats() not found in {script.name} — " + "was it renamed or removed?" + ) + bodies[script.name] = body + + reference_name = scripts[0].name + for script in scripts[1:]: + assert bodies[script.name] == bodies[reference_name], ( + f"_pg_firstaid_checkpoint_stats() in {script.name} differs from " + f"{reference_name} — keep these identical so the checkpoint stats " + "check produces consistent results regardless of install path." + ) + + @pytest.mark.integration @pytest.mark.parametrize("view_sql", _view_sql_files()) def test_view_variant_matches_function_check_names( - db_conn: psycopg.Connection[tuple[object, ...]], + db_conn: PgConnection, view_sql: Path, ) -> None: execute_sql_file(db_conn, view_sql) @@ -187,9 +258,74 @@ def test_view_variant_matches_function_check_names( execute_sql_file(db_conn, _default_view_sql()) +@pytest.mark.integration +@pytest.mark.parametrize("view_sql", _view_sql_files()) +def test_view_variant_installs_standalone( + db_conn: PgConnection, + view_sql: Path, +) -> None: + execute_sql_file(db_conn, _pgtap_teardown_sql()) + execute_sql_file(db_conn, _pgtap_setup_sql()) + + try: + execute_sql_file(db_conn, view_sql) + + with db_conn.cursor() as cur: + cur.execute("SELECT count(*) >= 0 FROM v_pgfirstaid") + assert cur.fetchone()[0] is True + finally: + execute_sql_file(db_conn, _pgtap_setup_sql()) + execute_sql_file(db_conn, _repo_root() / "pgFirstAid.sql") + execute_sql_file(db_conn, _default_view_sql()) + + +@pytest.mark.integration +def test_managed_view_duplicate_index_check_ignores_partial_indexes( + db_conn: PgConnection, + test_schema: str, +) -> None: + table_name = f"{test_schema}.partial_index_table" + managed_view = _repo_root() / "view_pgFirstAid_managed.sql" + + with db_conn.cursor() as cur: + cur.execute( + f""" + CREATE TABLE {table_name} ( + id serial PRIMARY KEY, + value integer NOT NULL + ) + """ + ) + cur.execute( + f"CREATE INDEX partial_idx_a ON {table_name} (value) WHERE value > 0" + ) + cur.execute( + f"CREATE INDEX partial_idx_b ON {table_name} (value) WHERE value > 10" + ) + + execute_sql_file(db_conn, managed_view) + + try: + with db_conn.cursor() as cur: + cur.execute( + """ + SELECT EXISTS ( + SELECT 1 + FROM v_pgfirstaid + WHERE check_name = 'Duplicate Index' + AND object_name LIKE %s + ) + """, + (f"{test_schema}.partial_index_table:%",), + ) + assert cur.fetchone()[0] is False + finally: + execute_sql_file(db_conn, _default_view_sql()) + + @pytest.mark.integration def test_view_parity_for_all_health_checks( - db_conn: psycopg.Connection[tuple[object, ...]], + db_conn: PgConnection, ) -> None: with db_conn.cursor() as cur: cur.execute( @@ -218,3 +354,100 @@ def test_view_parity_for_all_health_checks( "These checks exist in v_pgfirstaid but are missing in pg_firstAid(): " + ", ".join(extra_in_view) ) + + +@pytest.mark.integration +def test_view_matches_function_row_order( + db_conn: PgConnection, +) -> None: + with db_conn.cursor() as cur: + cur.execute( + """ + SELECT severity, category, check_name + FROM pg_firstAid() + ORDER BY severity, category, check_name + """ + ) + function_rows = [tuple(str(value) for value in row) for row in cur.fetchall()] + + cur.execute( + """ + SELECT severity, category, check_name + FROM v_pgfirstaid + ORDER BY severity, category, check_name + """ + ) + view_rows = [tuple(str(value) for value in row) for row in cur.fetchall()] + + assert function_rows == view_rows + + +@pytest.mark.integration +def test_wraparound_risk_current_value_is_human_readable( + db_conn: PgConnection, +) -> None: + expected_pattern = re.compile( + r"^[^:]+: XID age [\d,]+ \([\d.]+% of wraparound window, ~[\d,]+ remaining\)$" + ) + + with db_conn.cursor() as cur: + cur.execute( + """ + SELECT current_value + FROM pg_firstAid() + WHERE check_name = 'Transaction ID Wraparound Risk' + """ + ) + function_values = [str(row[0]) for row in cur.fetchall()] + + cur.execute( + """ + SELECT current_value + FROM v_pgfirstaid + WHERE check_name = 'Transaction ID Wraparound Risk' + """ + ) + view_values = [str(row[0]) for row in cur.fetchall()] + + assert function_values, "Expected wraparound risk rows from pg_firstAid()" + assert view_values, "Expected wraparound risk rows from v_pgfirstaid" + assert all(expected_pattern.match(value) for value in function_values), ( + function_values + ) + assert all(expected_pattern.match(value) for value in view_values), view_values + + +@pytest.mark.integration +def test_checkpoint_stats_guidance_matches_server_version( + db_conn: PgConnection, +) -> None: + with db_conn.cursor() as cur: + cur.execute("SELECT current_setting('server_version_num')::int") + server_version_num = cur.fetchone()[0] + + cur.execute( + """ + SELECT recommended_action + FROM pg_firstAid() + WHERE check_name = 'Checkpoint Stats' + """ + ) + function_action = str(cur.fetchone()[0]) + + cur.execute( + """ + SELECT recommended_action + FROM v_pgfirstaid + WHERE check_name = 'Checkpoint Stats' + """ + ) + view_action = str(cur.fetchone()[0]) + + expected_reset = ( + "pg_stat_reset_shared('checkpointer')" + if server_version_num >= 170000 + else "pg_stat_reset_shared('bgwriter')" + ) + + assert expected_reset in function_action + assert expected_reset in view_action diff --git a/testing/integration/tests/test_config.py b/testing/integration/tests/test_config.py new file mode 100644 index 0000000..3dd1cc0 --- /dev/null +++ b/testing/integration/tests/test_config.py @@ -0,0 +1,44 @@ +import pytest + +from pgfirstaid_pytest import TestConfig + + +def test_from_env_requires_connection_variables( + monkeypatch: pytest.MonkeyPatch, +) -> None: + for name in ("PGHOST", "PGPORT", "PGUSER", "PGDATABASE", "PGPASSWORD"): + monkeypatch.delenv(name, raising=False) + + with pytest.raises( + ValueError, match="Missing required PostgreSQL environment variables" + ): + TestConfig.from_env() + + +def test_missing_env_vars_reports_unset_connection_variables( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PGHOST", "db.example.test") + monkeypatch.delenv("PGPORT", raising=False) + monkeypatch.delenv("PGUSER", raising=False) + monkeypatch.setenv("PGDATABASE", "appdb") + + assert TestConfig.missing_env_vars() == ("PGPORT", "PGUSER") + + +def test_from_env_reads_standard_pg_variables(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PGHOST", "db.example.test") + monkeypatch.setenv("PGPORT", "6543") + monkeypatch.setenv("PGUSER", "pgfirstaid") + monkeypatch.setenv("PGDATABASE", "appdb") + monkeypatch.setenv("PGPASSWORD", "secret") + monkeypatch.setenv("PGSSLMODE", "require") + + config = TestConfig.from_env() + + assert config.host == "db.example.test" + assert config.port == 6543 + assert config.user == "pgfirstaid" + assert config.password == "secret" + assert config.database == "appdb" + assert config.sslmode == "require" diff --git a/testing/integration/uv.lock b/testing/integration/uv.lock index 449ba4c..cabf8d5 100644 --- a/testing/integration/uv.lock +++ b/testing/integration/uv.lock @@ -34,13 +34,13 @@ name = "pgfirstaid-python-tests" 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.2.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.11" }, { name = "pytest", specifier = ">=8.3.0" }, ] @@ -54,72 +54,55 @@ wheels = [ ] [[package]] -name = "psycopg" -version = "3.3.2" -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/e0/1a/7d9ef4fdc13ef7f15b934c393edc97a35c281bb7d3c3329fbfcbe915a7c2/psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7", size = 165630, upload-time = "2025-12-06T17:34:53.899Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/51/2779ccdf9305981a06b21a6b27e8547c948d85c41c76ff434192784a4c93/psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b", size = 212774, upload-time = "2025-12-06T17:31:41.414Z" }, -] - -[package.optional-dependencies] -binary = [ - { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, -] - -[[package]] -name = "psycopg-binary" -version = "3.3.2" +name = "psycopg2-binary" +version = "2.9.11" source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/d9/49640360fc090d27afc4655021544aa71d5393ebae124ffa53a04474b493/psycopg_binary-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:94503b79f7da0b65c80d0dbb2f81dd78b300319ec2435d5e6dcf9622160bc2fa", size = 4597890, upload-time = "2025-12-06T17:32:23.087Z" }, - { url = "https://files.pythonhosted.org/packages/85/cf/99634bbccc8af0dd86df4bce705eea5540d06bb7f5ab3067446ae9ffdae4/psycopg_binary-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07a5f030e0902ec3e27d0506ceb01238c0aecbc73ecd7fa0ee55f86134600b5b", size = 4664396, upload-time = "2025-12-06T17:32:26.421Z" }, - { url = "https://files.pythonhosted.org/packages/40/db/6035dff6d5c6dfca3a4ab0d2ac62ede623646e327e9f99e21e0cf08976c6/psycopg_binary-3.3.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e09d0d93d35c134704a2cb2b15f81ffc8174fd602f3e08f7b1a3d8896156cf0", size = 5478743, upload-time = "2025-12-06T17:32:29.901Z" }, - { url = "https://files.pythonhosted.org/packages/03/0f/fc06bbc8e87f09458d2ce04a59cd90565e54e8efca33e0802daee6d2b0e6/psycopg_binary-3.3.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:649c1d33bedda431e0c1df646985fbbeb9274afa964e1aef4be053c0f23a2924", size = 5151820, upload-time = "2025-12-06T17:32:33.562Z" }, - { url = "https://files.pythonhosted.org/packages/86/ab/bcc0397c96a0ad29463e33ed03285826e0fabc43595c195f419d9291ee70/psycopg_binary-3.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5774272f754605059521ff037a86e680342e3847498b0aa86b0f3560c70963c", size = 6747711, upload-time = "2025-12-06T17:32:38.074Z" }, - { url = "https://files.pythonhosted.org/packages/96/eb/7450bc75c31d5be5f7a6d02d26beef6989a4ca6f5efdec65eea6cf612d0e/psycopg_binary-3.3.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d391b70c9cc23f6e1142729772a011f364199d2c5ddc0d596f5f43316fbf982d", size = 4991626, upload-time = "2025-12-06T17:32:41.373Z" }, - { url = "https://files.pythonhosted.org/packages/dc/85/65f14453804c82a7fba31cd1a984b90349c0f327b809102c4b99115c0930/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f3f601f32244a677c7b029ec39412db2772ad04a28bc2cbb4b1f0931ed0ffad7", size = 4516760, upload-time = "2025-12-06T17:32:44.921Z" }, - { url = "https://files.pythonhosted.org/packages/24/8c/3105f00a91d73d9a443932f95156eae8159d5d9cb68a9d2cf512710d484f/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0ae60e910531cfcc364a8f615a7941cac89efeb3f0fffe0c4824a6d11461eef7", size = 4204028, upload-time = "2025-12-06T17:32:48.355Z" }, - { url = "https://files.pythonhosted.org/packages/1e/dd/74f64a383342ef7c22d1eb2768ed86411c7f877ed2580cd33c17f436fe3c/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c43a773dd1a481dbb2fe64576aa303d80f328cce0eae5e3e4894947c41d1da7", size = 3935780, upload-time = "2025-12-06T17:32:51.347Z" }, - { url = "https://files.pythonhosted.org/packages/85/30/f3f207d1c292949a26cdea6727c9c325b4ee41e04bf2736a4afbe45eb61f/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5a327327f1188b3fbecac41bf1973a60b86b2eb237db10dc945bd3dc97ec39e4", size = 4243239, upload-time = "2025-12-06T17:32:54.924Z" }, - { url = "https://files.pythonhosted.org/packages/b3/08/8f1b5d6231338bf7bc46f635c4d4965facec52e1c9a7952ca8a70cb57dc0/psycopg_binary-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:136c43f185244893a527540307167f5d3ef4e08786508afe45d6f146228f5aa9", size = 3548102, upload-time = "2025-12-06T17:32:57.944Z" }, - { url = "https://files.pythonhosted.org/packages/4e/1e/8614b01c549dd7e385dacdcd83fe194f6b3acb255a53cc67154ee6bf00e7/psycopg_binary-3.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9387ab615f929e71ef0f4a8a51e986fa06236ccfa9f3ec98a88f60fbf230634", size = 4579832, upload-time = "2025-12-06T17:33:01.388Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/0bb093570fae2f4454d42c1ae6000f15934391867402f680254e4a7def54/psycopg_binary-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3ff7489df5e06c12d1829544eaec64970fe27fe300f7cf04c8495fe682064688", size = 4658786, upload-time = "2025-12-06T17:33:05.022Z" }, - { url = "https://files.pythonhosted.org/packages/61/20/1d9383e3f2038826900a14137b0647d755f67551aab316e1021443105ed5/psycopg_binary-3.3.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9742580ecc8e1ac45164e98d32ca6df90da509c2d3ff26be245d94c430f92db4", size = 5454896, upload-time = "2025-12-06T17:33:09.023Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/513c80ad8bbb545e364f7737bf2492d34a4c05eef4f7b5c16428dc42260d/psycopg_binary-3.3.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d45acedcaa58619355f18e0f42af542fcad3fd84ace4b8355d3a5dea23318578", size = 5132731, upload-time = "2025-12-06T17:33:12.519Z" }, - { url = "https://files.pythonhosted.org/packages/f3/28/ddf5f5905f088024bccb19857949467407c693389a14feb527d6171d8215/psycopg_binary-3.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d88f32ff8c47cb7f4e7e7a9d1747dcee6f3baa19ed9afa9e5694fd2fb32b61ed", size = 6724495, upload-time = "2025-12-06T17:33:16.624Z" }, - { url = "https://files.pythonhosted.org/packages/6e/93/a1157ebcc650960b264542b547f7914d87a42ff0cc15a7584b29d5807e6b/psycopg_binary-3.3.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59d0163c4617a2c577cb34afbed93d7a45b8c8364e54b2bd2020ff25d5f5f860", size = 4964979, upload-time = "2025-12-06T17:33:20.179Z" }, - { url = "https://files.pythonhosted.org/packages/0e/27/65939ba6798f9c5be4a5d9cd2061ebaf0851798525c6811d347821c8132d/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e750afe74e6c17b2c7046d2c3e3173b5a3f6080084671c8aa327215323df155b", size = 4493648, upload-time = "2025-12-06T17:33:23.464Z" }, - { url = "https://files.pythonhosted.org/packages/8a/c4/5e9e4b9b1c1e27026e43387b0ba4aaf3537c7806465dd3f1d5bde631752a/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f26f113013c4dcfbfe9ced57b5bad2035dda1a7349f64bf726021968f9bccad3", size = 4173392, upload-time = "2025-12-06T17:33:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/c6/81/cf43fb76993190cee9af1cbcfe28afb47b1928bdf45a252001017e5af26e/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8309ee4569dced5e81df5aa2dcd48c7340c8dee603a66430f042dfbd2878edca", size = 3909241, upload-time = "2025-12-06T17:33:30.092Z" }, - { url = "https://files.pythonhosted.org/packages/9d/20/c6377a0d17434674351627489deca493ea0b137c522b99c81d3a106372c8/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6464150e25b68ae3cb04c4e57496ea11ebfaae4d98126aea2f4702dd43e3c12", size = 4219746, upload-time = "2025-12-06T17:33:33.097Z" }, - { url = "https://files.pythonhosted.org/packages/25/32/716c57b28eefe02a57a4c9d5bf956849597f5ea476c7010397199e56cfde/psycopg_binary-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:716a586f99bbe4f710dc58b40069fcb33c7627e95cc6fc936f73c9235e07f9cf", size = 3537494, upload-time = "2025-12-06T17:33:35.82Z" }, - { url = "https://files.pythonhosted.org/packages/14/73/7ca7cb22b9ac7393fb5de7d28ca97e8347c375c8498b3bff2c99c1f38038/psycopg_binary-3.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc5a189e89cbfff174588665bb18d28d2d0428366cc9dae5864afcaa2e57380b", size = 4579068, upload-time = "2025-12-06T17:33:39.303Z" }, - { url = "https://files.pythonhosted.org/packages/f5/42/0cf38ff6c62c792fc5b55398a853a77663210ebd51ed6f0c4a05b06f95a6/psycopg_binary-3.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:083c2e182be433f290dc2c516fd72b9b47054fcd305cce791e0a50d9e93e06f2", size = 4657520, upload-time = "2025-12-06T17:33:42.536Z" }, - { url = "https://files.pythonhosted.org/packages/3b/60/df846bc84cbf2231e01b0fff48b09841fe486fa177665e50f4995b1bfa44/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ac230e3643d1c436a2dfb59ca84357dfc6862c9f372fc5dbd96bafecae581f9f", size = 5452086, upload-time = "2025-12-06T17:33:46.54Z" }, - { url = "https://files.pythonhosted.org/packages/ab/85/30c846a00db86b1b53fd5bfd4b4edfbd0c00de8f2c75dd105610bd7568fc/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8c899a540f6c7585cee53cddc929dd4d2db90fd828e37f5d4017b63acbc1a5d", size = 5131125, upload-time = "2025-12-06T17:33:50.413Z" }, - { url = "https://files.pythonhosted.org/packages/6d/15/9968732013373f36f8a2a3fb76104dffc8efd9db78709caa5ae1a87b1f80/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50ff10ab8c0abdb5a5451b9315538865b50ba64c907742a1385fdf5f5772b73e", size = 6722914, upload-time = "2025-12-06T17:33:54.544Z" }, - { url = "https://files.pythonhosted.org/packages/b2/ba/29e361fe02143ac5ff5a1ca3e45697344cfbebe2eaf8c4e7eec164bff9a0/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23d2594af848c1fd3d874a9364bef50730124e72df7bb145a20cb45e728c50ed", size = 4966081, upload-time = "2025-12-06T17:33:58.477Z" }, - { url = "https://files.pythonhosted.org/packages/99/45/1be90c8f1a1a237046903e91202fb06708745c179f220b361d6333ed7641/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fe6b4ead3bbbe27244ea224fcd1f53cb119afc38b71a2f3ce570149a03e30", size = 4493332, upload-time = "2025-12-06T17:34:02.011Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/bbdc07d5f0a5e90c617abd624368182aa131485e18038b2c6c85fc054aed/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:742ce48cde825b8e52fb1a658253d6d1ff66d152081cbc76aa45e2986534858d", size = 4170781, upload-time = "2025-12-06T17:34:05.298Z" }, - { url = "https://files.pythonhosted.org/packages/d1/2a/0d45e4f4da2bd78c3237ffa03475ef3751f69a81919c54a6e610eb1a7c96/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e22bf6b54df994aff37ab52695d635f1ef73155e781eee1f5fa75bc08b58c8da", size = 3910544, upload-time = "2025-12-06T17:34:08.251Z" }, - { url = "https://files.pythonhosted.org/packages/3a/62/a8e0f092f4dbef9a94b032fb71e214cf0a375010692fbe7493a766339e47/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8db9034cde3bcdafc66980f0130813f5c5d19e74b3f2a19fb3cfbc25ad113121", size = 4220070, upload-time = "2025-12-06T17:34:11.392Z" }, - { url = "https://files.pythonhosted.org/packages/09/e6/5fc8d8aff8afa114bb4a94a0341b9309311e8bf3ab32d816032f8b984d4e/psycopg_binary-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:df65174c7cf6b05ea273ce955927d3270b3a6e27b0b12762b009ce6082b8d3fc", size = 3540922, upload-time = "2025-12-06T17:34:14.88Z" }, - { url = "https://files.pythonhosted.org/packages/bd/75/ad18c0b97b852aba286d06befb398cc6d383e9dfd0a518369af275a5a526/psycopg_binary-3.3.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9ca24062cd9b2270e4d77576042e9cc2b1d543f09da5aba1f1a3d016cea28390", size = 4596371, upload-time = "2025-12-06T17:34:18.007Z" }, - { url = "https://files.pythonhosted.org/packages/5a/79/91649d94c8d89f84af5da7c9d474bfba35b08eb8f492ca3422b08f0a6427/psycopg_binary-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c749770da0947bc972e512f35366dd4950c0e34afad89e60b9787a37e97cb443", size = 4675139, upload-time = "2025-12-06T17:34:21.374Z" }, - { url = "https://files.pythonhosted.org/packages/56/ac/b26e004880f054549ec9396594e1ffe435810b0673e428e619ed722e4244/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03b7cd73fb8c45d272a34ae7249713e32492891492681e3cf11dff9531cf37e9", size = 5456120, upload-time = "2025-12-06T17:34:25.102Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/410681dccd6f2999fb115cc248521ec50dd2b0aba66ae8de7e81efdebbee/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43b130e3b6edcb5ee856c7167ccb8561b473308c870ed83978ae478613764f1c", size = 5133484, upload-time = "2025-12-06T17:34:28.933Z" }, - { url = "https://files.pythonhosted.org/packages/66/30/ebbab99ea2cfa099d7b11b742ce13415d44f800555bfa4ad2911dc645b71/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1feba5a8c617922321aef945865334e468337b8fc5c73074f5e63143013b5a", size = 6731818, upload-time = "2025-12-06T17:34:33.094Z" }, - { url = "https://files.pythonhosted.org/packages/70/02/d260646253b7ad805d60e0de47f9b811d6544078452579466a098598b6f4/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cabb2a554d9a0a6bf84037d86ca91782f087dfff2a61298d0b00c19c0bc43f6d", size = 4983859, upload-time = "2025-12-06T17:34:36.457Z" }, - { url = "https://files.pythonhosted.org/packages/72/8d/e778d7bad1a7910aa36281f092bd85c5702f508fd9bb0ea2020ffbb6585c/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74bc306c4b4df35b09bc8cecf806b271e1c5d708f7900145e4e54a2e5dedfed0", size = 4516388, upload-time = "2025-12-06T17:34:40.129Z" }, - { url = "https://files.pythonhosted.org/packages/bd/f1/64e82098722e2ab3521797584caf515284be09c1e08a872551b6edbb0074/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d79b0093f0fbf7a962d6a46ae292dc056c65d16a8ee9361f3cfbafd4c197ab14", size = 4192382, upload-time = "2025-12-06T17:34:43.279Z" }, - { url = "https://files.pythonhosted.org/packages/fa/d0/c20f4e668e89494972e551c31be2a0016e3f50d552d7ae9ac07086407599/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1586e220be05547c77afc326741dd41cc7fba38a81f9931f616ae98865439678", size = 3928660, upload-time = "2025-12-06T17:34:46.757Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e1/99746c171de22539fd5eb1c9ca21dc805b54cfae502d7451d237d1dbc349/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:458696a5fa5dad5b6fb5d5862c22454434ce4fe1cf66ca6c0de5f904cbc1ae3e", size = 4239169, upload-time = "2025-12-06T17:34:49.751Z" }, - { url = "https://files.pythonhosted.org/packages/72/f7/212343c1c9cfac35fd943c527af85e9091d633176e2a407a0797856ff7b9/psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1", size = 3642122, upload-time = "2025-12-06T17:34:52.506Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ae/8d8266f6dd183ab4d48b95b9674034e1b482a3f8619b33a0d86438694577/psycopg2_binary-2.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0e8480afd62362d0a6a27dd09e4ca2def6fa50ed3a4e7c09165266106b2ffa10", size = 3756452, upload-time = "2025-10-10T11:11:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/aa03d327739c1be70e09d01182619aca8ebab5970cd0cfa50dd8b9cec2ac/psycopg2_binary-2.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:763c93ef1df3da6d1a90f86ea7f3f806dc06b21c198fa87c3c25504abec9404a", size = 3863957, upload-time = "2025-10-10T11:11:16.932Z" }, + { url = "https://files.pythonhosted.org/packages/48/89/3fdb5902bdab8868bbedc1c6e6023a4e08112ceac5db97fc2012060e0c9a/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e164359396576a3cc701ba8af4751ae68a07235d7a380c631184a611220d9a4", size = 4410955, upload-time = "2025-10-10T11:11:21.21Z" }, + { url = "https://files.pythonhosted.org/packages/ce/24/e18339c407a13c72b336e0d9013fbbbde77b6fd13e853979019a1269519c/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d57c9c387660b8893093459738b6abddbb30a7eab058b77b0d0d1c7d521ddfd7", size = 4468007, upload-time = "2025-10-10T11:11:24.831Z" }, + { url = "https://files.pythonhosted.org/packages/91/7e/b8441e831a0f16c159b5381698f9f7f7ed54b77d57bc9c5f99144cc78232/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2c226ef95eb2250974bf6fa7a842082b31f68385c4f3268370e3f3870e7859ee", size = 4165012, upload-time = "2025-10-10T11:11:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/0d/61/4aa89eeb6d751f05178a13da95516c036e27468c5d4d2509bb1e15341c81/psycopg2_binary-2.9.11-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a311f1edc9967723d3511ea7d2708e2c3592e3405677bf53d5c7246753591fbb", size = 3981881, upload-time = "2025-10-30T02:55:07.332Z" }, + { url = "https://files.pythonhosted.org/packages/76/a1/2f5841cae4c635a9459fe7aca8ed771336e9383b6429e05c01267b0774cf/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb415404821b6d1c47353ebe9c8645967a5235e6d88f914147e7fd411419e6f", size = 3650985, upload-time = "2025-10-10T11:11:34.975Z" }, + { url = "https://files.pythonhosted.org/packages/84/74/4defcac9d002bca5709951b975173c8c2fa968e1a95dc713f61b3a8d3b6a/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f07c9c4a5093258a03b28fab9b4f151aa376989e7f35f855088234e656ee6a94", size = 3296039, upload-time = "2025-10-10T11:11:40.432Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c2/782a3c64403d8ce35b5c50e1b684412cf94f171dc18111be8c976abd2de1/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:00ce1830d971f43b667abe4a56e42c1e2d594b32da4802e44a73bacacb25535f", size = 3043477, upload-time = "2025-10-30T02:55:11.182Z" }, + { url = "https://files.pythonhosted.org/packages/c8/31/36a1d8e702aa35c38fc117c2b8be3f182613faa25d794b8aeaab948d4c03/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cffe9d7697ae7456649617e8bb8d7a45afb71cd13f7ab22af3e5c61f04840908", size = 3345842, upload-time = "2025-10-10T11:11:45.366Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b4/a5375cda5b54cb95ee9b836930fea30ae5a8f14aa97da7821722323d979b/psycopg2_binary-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:304fd7b7f97eef30e91b8f7e720b3db75fee010b520e434ea35ed1ff22501d03", size = 2713894, upload-time = "2025-10-10T11:11:48.775Z" }, + { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" }, + { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" }, + { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" }, + { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, + { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, + { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, ] [[package]] @@ -146,21 +129,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049dd wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] - -[[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 = "2025.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, -] diff --git a/testing/pgTAP/03_high_tests.sql b/testing/pgTAP/03_high_tests.sql index f73e7bc..51aa2fe 100644 --- a/testing/pgTAP/03_high_tests.sql +++ b/testing/pgTAP/03_high_tests.sql @@ -1,5 +1,5 @@ BEGIN; -SELECT plan(44); +SELECT plan(46); SELECT ok( (SELECT count(*) >= 0 FROM pg_firstAid() WHERE check_name = 'Current Blocked/Blocking Queries'), @@ -199,5 +199,14 @@ SELECT ok( 'View executes Long Running Queries check' ); +SELECT ok( + (SELECT count(*) >= 0 FROM pg_firstAid() WHERE check_name = 'shared_buffers At Default'), + 'Function executes shared_buffers At Default check' +); +SELECT ok( + (SELECT count(*) >= 0 FROM v_pgfirstaid WHERE check_name = 'shared_buffers At Default'), + 'View executes shared_buffers At Default check' +); + SELECT * FROM finish(); ROLLBACK; diff --git a/testing/pgTAP/04_medium_tests.sql b/testing/pgTAP/04_medium_tests.sql index 830eb39..e306733 100644 --- a/testing/pgTAP/04_medium_tests.sql +++ b/testing/pgTAP/04_medium_tests.sql @@ -1,5 +1,5 @@ BEGIN; -SELECT plan(14); +SELECT plan(16); SELECT ok( (SELECT count(*) >= 0 FROM pg_firstAid() WHERE check_name = 'Missing FK Index'), @@ -64,5 +64,14 @@ SELECT ok( 'View executes Empty Table check' ); +SELECT ok( + (SELECT count(*) >= 0 FROM pg_firstAid() WHERE check_name = 'work_mem At Default'), + 'Function executes work_mem At Default check' +); +SELECT ok( + (SELECT count(*) >= 0 FROM v_pgfirstaid WHERE check_name = 'work_mem At Default'), + 'View executes work_mem At Default check' +); + SELECT * FROM finish(); ROLLBACK; diff --git a/testing/pgTAP/06_info_tests.sql b/testing/pgTAP/06_info_tests.sql index 9314369..a7f3fd0 100644 --- a/testing/pgTAP/06_info_tests.sql +++ b/testing/pgTAP/06_info_tests.sql @@ -1,5 +1,5 @@ BEGIN; -SELECT plan(4); +SELECT plan(20); SELECT ok((SELECT count(*) >= 0 FROM pg_firstAid()), 'pg_firstAid() executes'); SELECT ok((SELECT count(*) >= 0 FROM v_pgfirstaid), 'v_pgfirstaid executes'); @@ -14,5 +14,77 @@ SELECT ok( 'View returns non-null check names' ); +SELECT ok( + (SELECT count(*) >= 1 FROM pg_firstAid() WHERE check_name = 'shared_buffers Setting'), + 'Function executes shared_buffers Setting check' +); +SELECT ok( + (SELECT count(*) >= 1 FROM v_pgfirstaid WHERE check_name = 'shared_buffers Setting'), + 'View executes shared_buffers Setting check' +); + +SELECT ok( + (SELECT count(*) >= 1 FROM pg_firstAid() WHERE check_name = 'work_mem Setting'), + 'Function executes work_mem Setting check' +); +SELECT ok( + (SELECT count(*) >= 1 FROM v_pgfirstaid WHERE check_name = 'work_mem Setting'), + 'View executes work_mem Setting check' +); + +SELECT ok( + (SELECT count(*) >= 1 FROM pg_firstAid() WHERE check_name = 'effective_cache_size Setting'), + 'Function executes effective_cache_size Setting check' +); +SELECT ok( + (SELECT count(*) >= 1 FROM v_pgfirstaid WHERE check_name = 'effective_cache_size Setting'), + 'View executes effective_cache_size Setting check' +); + +SELECT ok( + (SELECT count(*) >= 1 FROM pg_firstAid() WHERE check_name = 'maintenance_work_mem Setting'), + 'Function executes maintenance_work_mem Setting check' +); +SELECT ok( + (SELECT count(*) >= 1 FROM v_pgfirstaid WHERE check_name = 'maintenance_work_mem Setting'), + 'View executes maintenance_work_mem Setting check' +); + +SELECT ok( + (SELECT count(*) >= 1 FROM pg_firstAid() WHERE check_name = 'Transaction ID Wraparound Risk'), + 'Function executes Transaction ID Wraparound Risk check' +); +SELECT ok( + (SELECT count(*) >= 1 FROM v_pgfirstaid WHERE check_name = 'Transaction ID Wraparound Risk'), + 'View executes Transaction ID Wraparound Risk check' +); + +SELECT ok( + (SELECT count(*) >= 1 FROM pg_firstAid() WHERE check_name = 'Checkpoint Stats'), + 'Function executes Checkpoint Stats check' +); +SELECT ok( + (SELECT count(*) >= 1 FROM v_pgfirstaid WHERE check_name = 'Checkpoint Stats'), + 'View executes Checkpoint Stats check' +); + +SELECT ok( + (SELECT count(*) >= 1 FROM pg_firstAid() WHERE check_name = 'Server Role'), + 'Function executes Server Role check' +); +SELECT ok( + (SELECT count(*) >= 1 FROM v_pgfirstaid WHERE check_name = 'Server Role'), + 'View executes Server Role check' +); + +SELECT ok( + (SELECT count(*) >= 1 FROM pg_firstAid() WHERE check_name = 'Connection Utilization'), + 'Function executes Connection Utilization check' +); +SELECT ok( + (SELECT count(*) >= 1 FROM v_pgfirstaid WHERE check_name = 'Connection Utilization'), + 'View executes Connection Utilization check' +); + SELECT * FROM finish(); ROLLBACK; diff --git a/testing/pgTAP/99_teardown.sql b/testing/pgTAP/99_teardown.sql index c5054cc..23f6af1 100644 --- a/testing/pgTAP/99_teardown.sql +++ b/testing/pgTAP/99_teardown.sql @@ -1,2 +1,5 @@ -- Session-level pgTAP teardown for Python harness DROP SCHEMA IF EXISTS pgfirstaid_test CASCADE; +DROP VIEW IF EXISTS v_pgfirstaid; +DROP FUNCTION IF EXISTS pg_firstAid(); +DROP FUNCTION IF EXISTS _pg_firstaid_checkpoint_stats(); diff --git a/view_pgFirstAid.sql b/view_pgFirstAid.sql index 22f9eab..857dc68 100644 --- a/view_pgFirstAid.sql +++ b/view_pgFirstAid.sql @@ -271,7 +271,48 @@ $$ language plpgsql; -- This way we start with a fresh view. drop view if exists v_pgfirstAid; +create or replace +function _pg_firstaid_checkpoint_stats() +returns text +language plpgsql +stable +as $$ +declare + v_timed bigint; + v_forced bigint; +begin + if current_setting('server_version_num')::int >= 170000 then + select num_timed, num_requested + into v_timed, v_forced + from pg_stat_checkpointer; + else + select checkpoints_timed, checkpoints_req + into v_timed, v_forced + from pg_stat_bgwriter; + end if; + + return 'timed: ' || v_timed::text || + ', forced: ' || v_forced::text || + ', forced ratio: ' || + case + when v_timed + v_forced = 0 then '0%' + else round(100.0 * v_forced / (v_timed + v_forced), 1)::text || '%' + end; +end; +$$; + create view v_pgfirstAid as +select + health_results.severity, + health_results.category, + health_results.check_name, + health_results.object_name, + health_results.issue_description, + health_results.current_value, + health_results.recommended_action, + health_results.documentation_link, + health_results.severity_order +from ( -- CRITICAL: Tables without primary keys select 'CRITICAL' as severity, @@ -1393,4 +1434,150 @@ select ' as documentation_link, 5 as severity_order from - ls); + ls) +union all +-- INFO: shared_buffers current value +select + 'INFO' as severity, + 'System Health' as category, + 'shared_buffers Setting' as check_name, + 'System' as object_name, + 'Current value of shared_buffers. Recommended: ~25% of total system RAM for dedicated database servers.' as issue_description, + current_setting('shared_buffers') as current_value, + 'No action needed if already tuned. For dedicated DB servers with 8GB+ RAM, target 25% of total RAM. Changes require a PostgreSQL restart.' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS' as documentation_link, + 5 as severity_order +union all +-- HIGH: shared_buffers still at 128MB PostgreSQL default +select + 'HIGH' as severity, + 'System Health' as category, + 'shared_buffers At Default' as check_name, + 'System' as object_name, + 'shared_buffers is set to the PostgreSQL default of 128MB. On any real workload this is almost certainly too low.' as issue_description, + current_setting('shared_buffers') as current_value, + 'Set shared_buffers to approximately 25% of total system RAM (e.g., 2GB on an 8GB server). Requires a PostgreSQL restart.' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS' as documentation_link, + 2 as severity_order +where pg_size_bytes(current_setting('shared_buffers')) = pg_size_bytes('128MB') +union all +-- INFO: work_mem current value +select + 'INFO' as severity, + 'System Health' as category, + 'work_mem Setting' as check_name, + 'System' as object_name, + 'Current value of work_mem. Allocated per sort/hash operation per session — multiply by max_connections and parallel workers to estimate peak memory consumption.' as issue_description, + current_setting('work_mem') || ' (max_connections: ' || current_setting('max_connections') || ')' as current_value, + 'For OLTP workloads, 16-32MB is a common starting point. Monitor pg_stat_statements for temp file spills to determine if higher is warranted. Use SET work_mem per-session for large one-off queries rather than setting globally.' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM' as documentation_link, + 5 as severity_order +union all +-- MEDIUM: work_mem still at 4MB PostgreSQL default +select + 'MEDIUM' as severity, + 'System Health' as category, + 'work_mem At Default' as check_name, + 'System' as object_name, + 'work_mem is set to the PostgreSQL default of 4MB. On modern hardware this often causes unnecessary sort and hash spills to disk.' as issue_description, + current_setting('work_mem') as current_value, + 'Consider raising work_mem to 16-32MB for OLTP workloads. Be aware that work_mem is allocated per operation per session — high concurrency multiplies total memory usage.' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM' as documentation_link, + 3 as severity_order +where pg_size_bytes(current_setting('work_mem')) = pg_size_bytes('4MB') +union all +-- INFO: effective_cache_size current value +select + 'INFO' as severity, + 'System Health' as category, + 'effective_cache_size Setting' as check_name, + 'System' as object_name, + 'Current value of effective_cache_size. Tells the query planner how much memory is available for disk caching. Does not allocate memory — purely advisory.' as issue_description, + current_setting('effective_cache_size') as current_value, + 'Set to ~50-75% of total system RAM (shared_buffers + expected OS page cache). Underestimates cause the planner to prefer nested loops over index scans.' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-EFFECTIVE-CACHE-SIZE' as documentation_link, + 5 as severity_order +union all +-- INFO: maintenance_work_mem current value +select + 'INFO' as severity, + 'System Health' as category, + 'maintenance_work_mem Setting' as check_name, + 'System' as object_name, + 'Current value of maintenance_work_mem. Used by VACUUM, CREATE INDEX, ALTER TABLE, and each autovacuum worker.' as issue_description, + current_setting('maintenance_work_mem') as current_value, + 'Consider 256MB-1GB on modern hardware. Higher values speed up index builds and autovacuum on large tables. Changes take effect immediately for new sessions.' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAINTENANCE-WORK-MEM' as documentation_link, + 5 as severity_order +union all +-- INFO: Transaction ID wraparound risk per database +select + 'INFO' as severity, + 'System Health' as category, + 'Transaction ID Wraparound Risk' as check_name, + datname as object_name, + 'Age of the oldest unfrozen transaction ID in this database. PostgreSQL must freeze XIDs before reaching ~2.1 billion to prevent data loss from wraparound.' as issue_description, + datname || ': XID age ' || trim(to_char(age(datfrozenxid), 'FM999,999,999,990')) || + ' (' || round(age(datfrozenxid)::numeric * 100 / 2000000000, 1)::text || + '% of wraparound window, ~' || + trim(to_char(greatest(2000000000::bigint - age(datfrozenxid)::bigint, 0), 'FM999,999,999,990')) || + ' remaining)' as current_value, + 'Run VACUUM FREEZE on databases approaching high XID age. Ensure autovacuum is enabled and not blocked. Monitor databases with age > 500,000,000.' as recommended_action, + 'https://www.postgresql.org/docs/current/routine-vacuuming.html#VACUUM-FOR-WRAPAROUND' as documentation_link, + 5 as severity_order +from + pg_database +where + datallowconn = true +union all +-- INFO: Checkpoint statistics (PG15/16: pg_stat_bgwriter, PG17+: pg_stat_checkpointer) +select + 'INFO' as severity, + 'System Health' as category, + 'Checkpoint Stats' as check_name, + 'System' as object_name, + 'Checkpoint activity since stats last reset. Forced checkpoints occur when WAL fills up before the scheduled interval — high ratios suggest max_wal_size may be too small. PG15/16 reads from pg_stat_bgwriter; PG17+ reads from pg_stat_checkpointer.' as issue_description, + _pg_firstaid_checkpoint_stats() as current_value, + 'If forced checkpoints are consistently above 50% of total, consider increasing max_wal_size. Reset stats with: SELECT pg_stat_reset_shared(''' || + case + when current_setting('server_version_num')::int >= 170000 then 'checkpointer' + else 'bgwriter' + end || + ''').' as recommended_action, + 'https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-BGWRITER-VIEW' as documentation_link, + 5 as severity_order +union all +-- INFO: Server role (primary vs standby) +select + 'INFO' as severity, + 'System Info' as category, + 'Server Role' as check_name, + 'System' as object_name, + 'Whether this server is operating as a primary or standby replica. Context for interpreting other checks — some checks are only relevant on standbys.' as issue_description, + case + when pg_is_in_recovery() then 'Standby (replica)' + else 'Primary' + end as current_value, + 'No action needed — informational.' as recommended_action, + 'https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-RECOVERY-INFO-TABLE' as documentation_link, + 5 as severity_order +union all +-- INFO: Connection utilization +select + 'INFO' as severity, + 'System Health' as category, + 'Connection Utilization' as check_name, + 'System' as object_name, + 'Current connection usage as a percentage of max_connections. Includes all connection states, not just active queries.' as issue_description, + count(*)::text || ' total / ' || current_setting('max_connections') || ' max (' || + round(100.0 * count(*) / current_setting('max_connections')::int, 1)::text || '% used)' as current_value, + 'If consistently above 80%, consider a connection pooler such as PgBouncer. Reserve headroom for superuser connections (superuser_reserved_connections).' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-connection.html' as documentation_link, + 5 as severity_order +from + pg_stat_activity +) health_results +order by + health_results.severity_order, + health_results.category, + health_results.check_name; diff --git a/view_pgFirstAid_managed.sql b/view_pgFirstAid_managed.sql index 6b2ff2d..426cdbc 100644 --- a/view_pgFirstAid_managed.sql +++ b/view_pgFirstAid_managed.sql @@ -271,7 +271,48 @@ $$ language plpgsql; -- This way we start with a fresh view. drop view if exists v_pgfirstAid; +create or replace +function _pg_firstaid_checkpoint_stats() +returns text +language plpgsql +stable +as $$ +declare + v_timed bigint; + v_forced bigint; +begin + if current_setting('server_version_num')::int >= 170000 then + select num_timed, num_requested + into v_timed, v_forced + from pg_stat_checkpointer; + else + select checkpoints_timed, checkpoints_req + into v_timed, v_forced + from pg_stat_bgwriter; + end if; + + return 'timed: ' || v_timed::text || + ', forced: ' || v_forced::text || + ', forced ratio: ' || + case + when v_timed + v_forced = 0 then '0%' + else round(100.0 * v_forced / (v_timed + v_forced), 1)::text || '%' + end; +end; +$$; + create view v_pgfirstAid as +select + health_results.severity, + health_results.category, + health_results.check_name, + health_results.object_name, + health_results.issue_description, + health_results.current_value, + health_results.recommended_action, + health_results.documentation_link, + health_results.severity_order +from ( -- CRITICAL: Tables without primary keys select 'CRITICAL' as severity, @@ -557,25 +598,33 @@ from ts) union all -- HIGH: Duplicate or redundant indexes +-- Compare actual index structure (columns, operator class) not string definitions select 'HIGH' as severity, 'Table Health' as category, 'Duplicate Index' as check_name, - quote_ident(i1.schemaname) || '.' || i1.indexname || ' & ' || i2.indexname as object_name, - 'Multiple indexes with identical or overlapping column sets' as issue_description, - 'Indexes: ' || i1.indexname || ', ' || i2.indexname as current_value, + quote_ident(n1.nspname) || '.' || quote_ident(c1.relname) || ': ' || quote_ident(i1.relname) || ' & ' || quote_ident(i2.relname) as object_name, + 'Multiple indexes with identical column sets and operator classes' as issue_description, + 'Indexes: ' || i1.relname || ', ' || i2.relname as current_value, 'Review and consolidate duplicate indexes and focus on keeping the most efficient one' as recommended_action, 'https://www.postgresql.org/docs/current/indexes-multicolumn.html' as documentation_link, 2 as severity_order from - pg_indexes i1 -join pg_indexes i2 on - i1.schemaname = i2.schemaname - and i1.tablename = i2.tablename - and i1.indexname < i2.indexname - and i1.indexdef = i2.indexdef + pg_index idx1 +join pg_class i1 on idx1.indexrelid = i1.oid +join pg_class c1 on idx1.indrelid = c1.oid +join pg_namespace n1 on c1.relnamespace = n1.oid +join pg_index idx2 on + idx1.indrelid = idx2.indrelid + and idx1.indexrelid < idx2.indexrelid + and idx1.indkey = idx2.indkey + and idx1.indclass = idx2.indclass + and idx1.indoption = idx2.indoption + and idx1.indpred is not distinct from idx2.indpred + and idx1.indexprs is not distinct from idx2.indexprs +join pg_class i2 on idx2.indexrelid = i2.oid where - i1.schemaname not like all(array['information_schema', 'pg_catalog', 'pg_toast', 'pg_temp%']) + n1.nspname not like all(array['information_schema', 'pg_catalog', 'pg_toast', 'pg_temp%']) union all -- HIGH: Table with more than 200 columns (with cc as ( @@ -1376,4 +1425,150 @@ select For GCP Cloud SQL: https://docs.cloud.google.com/sql/docs/postgres/logging / For Azure Database for PostgreSQL: https://learn.microsoft.com/en-us/cli/azure/postgres/flexible-server/server-logs?view=azure-cli-latest ' as documentation_link, - 5 as severity_order; + 5 as severity_order +union all +-- INFO: shared_buffers current value +select + 'INFO' as severity, + 'System Health' as category, + 'shared_buffers Setting' as check_name, + 'System' as object_name, + 'Current value of shared_buffers. Recommended: ~25% of total system RAM for dedicated database servers.' as issue_description, + current_setting('shared_buffers') as current_value, + 'No action needed if already tuned. For dedicated DB servers with 8GB+ RAM, target 25% of total RAM. Changes require a PostgreSQL restart.' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS' as documentation_link, + 5 as severity_order +union all +-- HIGH: shared_buffers still at 128MB PostgreSQL default +select + 'HIGH' as severity, + 'System Health' as category, + 'shared_buffers At Default' as check_name, + 'System' as object_name, + 'shared_buffers is set to the PostgreSQL default of 128MB. On any real workload this is almost certainly too low.' as issue_description, + current_setting('shared_buffers') as current_value, + 'Set shared_buffers to approximately 25% of total system RAM (e.g., 2GB on an 8GB server). Requires a PostgreSQL restart.' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS' as documentation_link, + 2 as severity_order +where pg_size_bytes(current_setting('shared_buffers')) = pg_size_bytes('128MB') +union all +-- INFO: work_mem current value +select + 'INFO' as severity, + 'System Health' as category, + 'work_mem Setting' as check_name, + 'System' as object_name, + 'Current value of work_mem. Allocated per sort/hash operation per session — multiply by max_connections and parallel workers to estimate peak memory consumption.' as issue_description, + current_setting('work_mem') || ' (max_connections: ' || current_setting('max_connections') || ')' as current_value, + 'For OLTP workloads, 16-32MB is a common starting point. Monitor pg_stat_statements for temp file spills to determine if higher is warranted. Use SET work_mem per-session for large one-off queries rather than setting globally.' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM' as documentation_link, + 5 as severity_order +union all +-- MEDIUM: work_mem still at 4MB PostgreSQL default +select + 'MEDIUM' as severity, + 'System Health' as category, + 'work_mem At Default' as check_name, + 'System' as object_name, + 'work_mem is set to the PostgreSQL default of 4MB. On modern hardware this often causes unnecessary sort and hash spills to disk.' as issue_description, + current_setting('work_mem') as current_value, + 'Consider raising work_mem to 16-32MB for OLTP workloads. Be aware that work_mem is allocated per operation per session — high concurrency multiplies total memory usage.' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM' as documentation_link, + 3 as severity_order +where pg_size_bytes(current_setting('work_mem')) = pg_size_bytes('4MB') +union all +-- INFO: effective_cache_size current value +select + 'INFO' as severity, + 'System Health' as category, + 'effective_cache_size Setting' as check_name, + 'System' as object_name, + 'Current value of effective_cache_size. Tells the query planner how much memory is available for disk caching. Does not allocate memory — purely advisory.' as issue_description, + current_setting('effective_cache_size') as current_value, + 'Set to ~50-75% of total system RAM (shared_buffers + expected OS page cache). Underestimates cause the planner to prefer nested loops over index scans.' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-EFFECTIVE-CACHE-SIZE' as documentation_link, + 5 as severity_order +union all +-- INFO: maintenance_work_mem current value +select + 'INFO' as severity, + 'System Health' as category, + 'maintenance_work_mem Setting' as check_name, + 'System' as object_name, + 'Current value of maintenance_work_mem. Used by VACUUM, CREATE INDEX, ALTER TABLE, and each autovacuum worker.' as issue_description, + current_setting('maintenance_work_mem') as current_value, + 'Consider 256MB-1GB on modern hardware. Higher values speed up index builds and autovacuum on large tables. Changes take effect immediately for new sessions.' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAINTENANCE-WORK-MEM' as documentation_link, + 5 as severity_order +union all +-- INFO: Transaction ID wraparound risk per database +select + 'INFO' as severity, + 'System Health' as category, + 'Transaction ID Wraparound Risk' as check_name, + datname as object_name, + 'Age of the oldest unfrozen transaction ID in this database. PostgreSQL must freeze XIDs before reaching ~2.1 billion to prevent data loss from wraparound.' as issue_description, + datname || ': XID age ' || trim(to_char(age(datfrozenxid), 'FM999,999,999,990')) || + ' (' || round(age(datfrozenxid)::numeric * 100 / 2000000000, 1)::text || + '% of wraparound window, ~' || + trim(to_char(greatest(2000000000::bigint - age(datfrozenxid)::bigint, 0), 'FM999,999,999,990')) || + ' remaining)' as current_value, + 'Run VACUUM FREEZE on databases approaching high XID age. Ensure autovacuum is enabled and not blocked. Monitor databases with age > 500,000,000.' as recommended_action, + 'https://www.postgresql.org/docs/current/routine-vacuuming.html#VACUUM-FOR-WRAPAROUND' as documentation_link, + 5 as severity_order +from + pg_database +where + datallowconn = true +union all +-- INFO: Checkpoint statistics (PG15/16: pg_stat_bgwriter, PG17+: pg_stat_checkpointer) +select + 'INFO' as severity, + 'System Health' as category, + 'Checkpoint Stats' as check_name, + 'System' as object_name, + 'Checkpoint activity since stats last reset. Forced checkpoints occur when WAL fills up before the scheduled interval — high ratios suggest max_wal_size may be too small. PG15/16 reads from pg_stat_bgwriter; PG17+ reads from pg_stat_checkpointer.' as issue_description, + _pg_firstaid_checkpoint_stats() as current_value, + 'If forced checkpoints are consistently above 50% of total, consider increasing max_wal_size. Reset stats with: SELECT pg_stat_reset_shared(''' || + case + when current_setting('server_version_num')::int >= 170000 then 'checkpointer' + else 'bgwriter' + end || + ''').' as recommended_action, + 'https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-BGWRITER-VIEW' as documentation_link, + 5 as severity_order +union all +-- INFO: Server role (primary vs standby) +select + 'INFO' as severity, + 'System Info' as category, + 'Server Role' as check_name, + 'System' as object_name, + 'Whether this server is operating as a primary or standby replica. Context for interpreting other checks — some checks are only relevant on standbys.' as issue_description, + case + when pg_is_in_recovery() then 'Standby (replica)' + else 'Primary' + end as current_value, + 'No action needed — informational.' as recommended_action, + 'https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-RECOVERY-INFO-TABLE' as documentation_link, + 5 as severity_order +union all +-- INFO: Connection utilization +select + 'INFO' as severity, + 'System Health' as category, + 'Connection Utilization' as check_name, + 'System' as object_name, + 'Current connection usage as a percentage of max_connections. Includes all connection states, not just active queries.' as issue_description, + count(*)::text || ' total / ' || current_setting('max_connections') || ' max (' || + round(100.0 * count(*) / current_setting('max_connections')::int, 1)::text || '% used)' as current_value, + 'If consistently above 80%, consider a connection pooler such as PgBouncer. Reserve headroom for superuser connections (superuser_reserved_connections).' as recommended_action, + 'https://www.postgresql.org/docs/current/runtime-config-connection.html' as documentation_link, + 5 as severity_order +from + pg_stat_activity +) health_results +order by + health_results.severity_order, + health_results.category, + health_results.check_name;