pgtriage 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+ *.egg
9
+ .venv/
10
+ venv/
11
+ .env
12
+ .pytest_cache/
13
+ .ruff_cache/
14
+ .mypy_cache/
pgtriage-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Manas Maheshwari
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: pgtriage
3
+ Version: 0.1.0
4
+ Summary: MCP server for PostgreSQL performance auditing
5
+ Author: Manas Maheshwari
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: audit,database,mcp,performance,postgresql
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Topic :: Database
15
+ Requires-Python: >=3.11
16
+ Requires-Dist: mcp[cli]>=1.25.0
17
+ Requires-Dist: psycopg[binary]>=3.2.0
18
+ Requires-Dist: pydantic>=2.12.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
21
+ Requires-Dist: pytest>=8.0; extra == 'dev'
22
+ Requires-Dist: ruff>=0.14; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # pgtriage
26
+
27
+ MCP server for PostgreSQL performance auditing. Connect it to Claude Code (or any MCP client) and say "audit my database" to get actionable performance findings with exact fixes.
28
+
29
+ ## How it works
30
+
31
+ ```
32
+ Claude Code (AI interpretation)
33
+ | MCP (stdio)
34
+ v
35
+ pgtriage (data collection + pattern detection)
36
+ | psycopg3 (read-only)
37
+ v
38
+ PostgreSQL database
39
+ ```
40
+
41
+ pgtriage connects to your PostgreSQL database and exposes performance auditing tools via the Model Context Protocol. It collects metrics from PostgreSQL system views, runs deterministic pattern detection, and returns structured findings. The MCP client provides the AI layer, interpreting results and explaining fixes in plain English.
42
+
43
+ No API keys required. No AI costs. The intelligence comes from your MCP client.
44
+
45
+ ## What it finds
46
+
47
+ - **Sequential scans on large tables** with missing index suggestions
48
+ - **Dead tuple buildup** and autovacuum health issues
49
+ - **Unused and duplicate indexes** wasting disk and slowing writes
50
+ - **N+1 query patterns** from pg_stat_statements analysis
51
+ - **Stale table statistics** causing bad query plans
52
+ - **TOAST table bloat** from large JSONB/TEXT columns
53
+ - **Configuration issues** (shared_buffers, work_mem, autovacuum tuning)
54
+ - **Connection pressure** approaching max_connections
55
+ - **Long-running queries** holding locks
56
+
57
+ ## Quick start
58
+
59
+ ### Install
60
+
61
+ ```bash
62
+ pip install pgtriage
63
+ ```
64
+
65
+ ### Configure Claude Code
66
+
67
+ Add to your MCP settings (`.claude/settings.json` or project settings):
68
+
69
+ ```json
70
+ {
71
+ "mcpServers": {
72
+ "pgtriage": {
73
+ "command": "python",
74
+ "args": ["-m", "pgtriage"],
75
+ "env": {
76
+ "PGAUDIT_CONNECTION_STRING": "postgres://user:pass@localhost:5432/dbname"
77
+ }
78
+ }
79
+ }
80
+ }
81
+ ```
82
+
83
+ ### Use
84
+
85
+ ```
86
+ > audit my database
87
+
88
+ > check table health for the users table
89
+
90
+ > are there any unused indexes?
91
+
92
+ > review my PostgreSQL configuration
93
+
94
+ > find slow queries
95
+ ```
96
+
97
+ ## Tools
98
+
99
+ ### `full_audit`
100
+ Run a comprehensive performance audit covering table health, slow queries, index health, and configuration. Returns all findings sorted by severity.
101
+
102
+ ### `check_table_health`
103
+ Analyze dead tuples, autovacuum stats, sequential scan ratios, and TOAST bloat. Optionally filter to a specific table.
104
+
105
+ ### `analyze_slow_queries`
106
+ Pull the slowest queries from `pg_stat_statements`, run `EXPLAIN ANALYZE` on each, and detect patterns like sequential scans, stale statistics, and N+1 queries.
107
+
108
+ ### `check_index_health`
109
+ Find unused indexes (zero scans), duplicate indexes (same column definition), and tables that likely need indexes based on scan patterns.
110
+
111
+ ### `check_config`
112
+ Review PostgreSQL settings (`shared_buffers`, `work_mem`, `autovacuum_vacuum_scale_factor`, `random_page_cost`, etc.) and flag suboptimal values. Checks connection utilization and long-running queries.
113
+
114
+ ## Resources
115
+
116
+ | Resource | Description |
117
+ |---|---|
118
+ | `pgtriage://status` | Connection status, PostgreSQL version, loaded extensions |
119
+ | `pgtriage://tables` | All tables with sizes and approximate row counts |
120
+
121
+ ## Requirements
122
+
123
+ - Python 3.11+
124
+ - PostgreSQL 12+
125
+ - `pg_stat_statements` extension (recommended for slow query analysis, not required for other tools)
126
+ - Database user with read access to `pg_stat_*` views
127
+
128
+ ## Safety
129
+
130
+ - All connections enforce `SET default_transaction_read_only = true`
131
+ - `EXPLAIN ANALYZE` only runs on `SELECT` queries (validated before execution)
132
+ - Connection strings are never exposed in tool outputs
133
+ - Single read-only connection, no write operations
134
+
135
+ ## Development
136
+
137
+ ```bash
138
+ git clone https://github.com/pgtriage/pgtriage.git
139
+ cd pgtriage
140
+ python3 -m venv .venv
141
+ source .venv/bin/activate
142
+ pip install -e ".[dev]"
143
+ pytest
144
+ ```
145
+
146
+ ## License
147
+
148
+ MIT
@@ -0,0 +1,124 @@
1
+ # pgtriage
2
+
3
+ MCP server for PostgreSQL performance auditing. Connect it to Claude Code (or any MCP client) and say "audit my database" to get actionable performance findings with exact fixes.
4
+
5
+ ## How it works
6
+
7
+ ```
8
+ Claude Code (AI interpretation)
9
+ | MCP (stdio)
10
+ v
11
+ pgtriage (data collection + pattern detection)
12
+ | psycopg3 (read-only)
13
+ v
14
+ PostgreSQL database
15
+ ```
16
+
17
+ pgtriage connects to your PostgreSQL database and exposes performance auditing tools via the Model Context Protocol. It collects metrics from PostgreSQL system views, runs deterministic pattern detection, and returns structured findings. The MCP client provides the AI layer, interpreting results and explaining fixes in plain English.
18
+
19
+ No API keys required. No AI costs. The intelligence comes from your MCP client.
20
+
21
+ ## What it finds
22
+
23
+ - **Sequential scans on large tables** with missing index suggestions
24
+ - **Dead tuple buildup** and autovacuum health issues
25
+ - **Unused and duplicate indexes** wasting disk and slowing writes
26
+ - **N+1 query patterns** from pg_stat_statements analysis
27
+ - **Stale table statistics** causing bad query plans
28
+ - **TOAST table bloat** from large JSONB/TEXT columns
29
+ - **Configuration issues** (shared_buffers, work_mem, autovacuum tuning)
30
+ - **Connection pressure** approaching max_connections
31
+ - **Long-running queries** holding locks
32
+
33
+ ## Quick start
34
+
35
+ ### Install
36
+
37
+ ```bash
38
+ pip install pgtriage
39
+ ```
40
+
41
+ ### Configure Claude Code
42
+
43
+ Add to your MCP settings (`.claude/settings.json` or project settings):
44
+
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "pgtriage": {
49
+ "command": "python",
50
+ "args": ["-m", "pgtriage"],
51
+ "env": {
52
+ "PGAUDIT_CONNECTION_STRING": "postgres://user:pass@localhost:5432/dbname"
53
+ }
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ ### Use
60
+
61
+ ```
62
+ > audit my database
63
+
64
+ > check table health for the users table
65
+
66
+ > are there any unused indexes?
67
+
68
+ > review my PostgreSQL configuration
69
+
70
+ > find slow queries
71
+ ```
72
+
73
+ ## Tools
74
+
75
+ ### `full_audit`
76
+ Run a comprehensive performance audit covering table health, slow queries, index health, and configuration. Returns all findings sorted by severity.
77
+
78
+ ### `check_table_health`
79
+ Analyze dead tuples, autovacuum stats, sequential scan ratios, and TOAST bloat. Optionally filter to a specific table.
80
+
81
+ ### `analyze_slow_queries`
82
+ Pull the slowest queries from `pg_stat_statements`, run `EXPLAIN ANALYZE` on each, and detect patterns like sequential scans, stale statistics, and N+1 queries.
83
+
84
+ ### `check_index_health`
85
+ Find unused indexes (zero scans), duplicate indexes (same column definition), and tables that likely need indexes based on scan patterns.
86
+
87
+ ### `check_config`
88
+ Review PostgreSQL settings (`shared_buffers`, `work_mem`, `autovacuum_vacuum_scale_factor`, `random_page_cost`, etc.) and flag suboptimal values. Checks connection utilization and long-running queries.
89
+
90
+ ## Resources
91
+
92
+ | Resource | Description |
93
+ |---|---|
94
+ | `pgtriage://status` | Connection status, PostgreSQL version, loaded extensions |
95
+ | `pgtriage://tables` | All tables with sizes and approximate row counts |
96
+
97
+ ## Requirements
98
+
99
+ - Python 3.11+
100
+ - PostgreSQL 12+
101
+ - `pg_stat_statements` extension (recommended for slow query analysis, not required for other tools)
102
+ - Database user with read access to `pg_stat_*` views
103
+
104
+ ## Safety
105
+
106
+ - All connections enforce `SET default_transaction_read_only = true`
107
+ - `EXPLAIN ANALYZE` only runs on `SELECT` queries (validated before execution)
108
+ - Connection strings are never exposed in tool outputs
109
+ - Single read-only connection, no write operations
110
+
111
+ ## Development
112
+
113
+ ```bash
114
+ git clone https://github.com/pgtriage/pgtriage.git
115
+ cd pgtriage
116
+ python3 -m venv .venv
117
+ source .venv/bin/activate
118
+ pip install -e ".[dev]"
119
+ pytest
120
+ ```
121
+
122
+ ## License
123
+
124
+ MIT
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pgtriage"
7
+ version = "0.1.0"
8
+ description = "MCP server for PostgreSQL performance auditing"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "Manas Maheshwari" }]
13
+ keywords = ["postgresql", "mcp", "performance", "audit", "database"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Database",
21
+ ]
22
+
23
+ dependencies = [
24
+ "mcp[cli]>=1.25.0",
25
+ "psycopg[binary]>=3.2.0",
26
+ "pydantic>=2.12.0",
27
+ ]
28
+
29
+ [project.scripts]
30
+ pgtriage = "pgtriage:main"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/pgtriage"]
34
+
35
+ [project.optional-dependencies]
36
+ dev = [
37
+ "pytest>=8.0",
38
+ "pytest-asyncio>=0.24",
39
+ "ruff>=0.14",
40
+ ]
@@ -0,0 +1,9 @@
1
+ """pgtriage - MCP server for PostgreSQL performance auditing."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+
6
+ def main():
7
+ from pgtriage.server import mcp
8
+
9
+ mcp.run()
@@ -0,0 +1,3 @@
1
+ from pgtriage import main
2
+
3
+ main()
File without changes
@@ -0,0 +1,159 @@
1
+ """Configuration recommendation rules."""
2
+
3
+ from pgtriage.models import Category, Finding, Severity
4
+
5
+
6
+ def _get_setting(settings: list[dict], name: str) -> str | None:
7
+ for s in settings:
8
+ if s["name"] == name:
9
+ return s["setting"]
10
+ return None
11
+
12
+
13
+ def _parse_memory_kb(value: str, unit: str | None) -> int:
14
+ """Convert a pg_settings memory value to KB."""
15
+ num = int(value)
16
+ if unit == "8kB":
17
+ return num * 8
18
+ if unit == "kB":
19
+ return num
20
+ if unit == "MB":
21
+ return num * 1024
22
+ if unit == "GB":
23
+ return num * 1024 * 1024
24
+ return num
25
+
26
+
27
+ def _get_setting_with_unit(settings: list[dict], name: str) -> tuple[str | None, str | None]:
28
+ for s in settings:
29
+ if s["name"] == name:
30
+ return s["setting"], s.get("unit")
31
+ return None, None
32
+
33
+
34
+ def analyze_config(
35
+ settings: list[dict],
36
+ connection_stats: dict | None,
37
+ ) -> list[Finding]:
38
+ findings = []
39
+
40
+ shared_buffers_val, shared_buffers_unit = _get_setting_with_unit(settings, "shared_buffers")
41
+ if shared_buffers_val and shared_buffers_unit:
42
+ shared_buffers_kb = _parse_memory_kb(shared_buffers_val, shared_buffers_unit)
43
+ if shared_buffers_kb < 128 * 1024:
44
+ findings.append(Finding(
45
+ severity=Severity.HIGH,
46
+ category=Category.CONFIG_ISSUE,
47
+ detail=(
48
+ f"shared_buffers is {shared_buffers_kb // 1024}MB. "
49
+ f"For production workloads, this should typically be 25% of available RAM "
50
+ f"(minimum 128MB for small instances)."
51
+ ),
52
+ suggested_fix="ALTER SYSTEM SET shared_buffers = '256MB'; -- then restart PostgreSQL",
53
+ requires_downtime=True,
54
+ evidence={"shared_buffers_kb": shared_buffers_kb},
55
+ ))
56
+
57
+ work_mem_val, work_mem_unit = _get_setting_with_unit(settings, "work_mem")
58
+ if work_mem_val and work_mem_unit:
59
+ work_mem_kb = _parse_memory_kb(work_mem_val, work_mem_unit)
60
+ if work_mem_kb <= 4 * 1024:
61
+ findings.append(Finding(
62
+ severity=Severity.LOW,
63
+ category=Category.CONFIG_ISSUE,
64
+ detail=(
65
+ f"work_mem is at default ({work_mem_kb // 1024}MB). "
66
+ f"Complex queries with sorts and hash joins may spill to disk. "
67
+ f"Consider increasing for workloads with complex queries."
68
+ ),
69
+ suggested_fix="ALTER SYSTEM SET work_mem = '16MB'; -- then SELECT pg_reload_conf();",
70
+ evidence={"work_mem_kb": work_mem_kb},
71
+ ))
72
+
73
+ autovacuum_sf = _get_setting(settings, "autovacuum_vacuum_scale_factor")
74
+ if autovacuum_sf:
75
+ sf_val = float(autovacuum_sf)
76
+ if sf_val > 0.1:
77
+ findings.append(Finding(
78
+ severity=Severity.MEDIUM,
79
+ category=Category.CONFIG_ISSUE,
80
+ detail=(
81
+ f"autovacuum_vacuum_scale_factor is {sf_val} (default 0.2). "
82
+ f"For large tables, this means autovacuum won't trigger until 20% of rows are dead. "
83
+ f"On a 10M row table, that's 2M dead rows before cleanup starts."
84
+ ),
85
+ suggested_fix=(
86
+ "For high-churn tables, set per-table: "
87
+ "ALTER TABLE <table> SET (autovacuum_vacuum_scale_factor = 0.01);"
88
+ ),
89
+ evidence={"autovacuum_vacuum_scale_factor": sf_val},
90
+ ))
91
+
92
+ random_page_cost = _get_setting(settings, "random_page_cost")
93
+ if random_page_cost and float(random_page_cost) > 1.5:
94
+ findings.append(Finding(
95
+ severity=Severity.LOW,
96
+ category=Category.CONFIG_ISSUE,
97
+ detail=(
98
+ f"random_page_cost is {random_page_cost} (default 4.0). "
99
+ f"If your database is on SSD storage, a value of 1.1 better reflects "
100
+ f"actual random read performance and helps the planner choose index scans."
101
+ ),
102
+ suggested_fix="ALTER SYSTEM SET random_page_cost = 1.1; -- then SELECT pg_reload_conf();",
103
+ evidence={"random_page_cost": float(random_page_cost)},
104
+ ))
105
+
106
+ log_min_duration = _get_setting(settings, "log_min_duration_statement")
107
+ if log_min_duration and int(log_min_duration) < 0:
108
+ findings.append(Finding(
109
+ severity=Severity.INFO,
110
+ category=Category.CONFIG_ISSUE,
111
+ detail=(
112
+ "log_min_duration_statement is disabled (-1). "
113
+ "Enabling it helps identify slow queries in PostgreSQL logs."
114
+ ),
115
+ suggested_fix=(
116
+ "ALTER SYSTEM SET log_min_duration_statement = 1000; "
117
+ "-- logs queries taking > 1 second"
118
+ ),
119
+ evidence={"log_min_duration_statement": int(log_min_duration)},
120
+ ))
121
+
122
+ if connection_stats:
123
+ total = connection_stats.get("total_connections", 0)
124
+ max_conn = connection_stats.get("max_connections", 100)
125
+ utilization = total / max(max_conn, 1) * 100
126
+
127
+ if utilization > 80:
128
+ findings.append(Finding(
129
+ severity=Severity.HIGH,
130
+ category=Category.CONNECTION_PRESSURE,
131
+ detail=(
132
+ f"Connection utilization at {utilization:.0f}% "
133
+ f"({total}/{max_conn}). "
134
+ f"Approaching max_connections limit."
135
+ ),
136
+ suggested_fix=(
137
+ "Consider using a connection pooler (PgBouncer) or "
138
+ "increasing max_connections if RAM allows."
139
+ ),
140
+ evidence={
141
+ "total_connections": total,
142
+ "max_connections": max_conn,
143
+ "utilization_pct": round(utilization, 1),
144
+ },
145
+ ))
146
+
147
+ long_running = connection_stats.get("long_running_queries", 0)
148
+ if long_running > 0:
149
+ findings.append(Finding(
150
+ severity=Severity.HIGH,
151
+ category=Category.LONG_RUNNING_QUERY,
152
+ detail=(
153
+ f"{long_running} queries running for more than 30 seconds. "
154
+ f"Long-running queries hold locks and prevent autovacuum."
155
+ ),
156
+ evidence={"long_running_queries": long_running},
157
+ ))
158
+
159
+ return findings
@@ -0,0 +1,128 @@
1
+ """EXPLAIN ANALYZE runner and execution plan analyzer."""
2
+
3
+ import re
4
+
5
+ from pgtriage.connection import ConnectionManager
6
+ from pgtriage.models import Category, Finding, Severity
7
+
8
+ SELECT_PATTERN = re.compile(r"^\s*SELECT\b", re.IGNORECASE)
9
+ STACKED_QUERY_PATTERN = re.compile(r";\s*\S")
10
+
11
+
12
+ async def run_explain_analyze(
13
+ db: ConnectionManager,
14
+ query: str,
15
+ ) -> dict | None:
16
+ """Run EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) on a SELECT query.
17
+ Returns the JSON plan or None if the query is not safe to run."""
18
+ if not SELECT_PATTERN.match(query):
19
+ return None
20
+ if STACKED_QUERY_PATTERN.search(query):
21
+ return None
22
+
23
+ clean_query = query.rstrip().rstrip(";")
24
+ explain_sql = f"EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) {clean_query}"
25
+
26
+ row = await db.fetch_one(explain_sql)
27
+ if row and "QUERY PLAN" in row:
28
+ return row["QUERY PLAN"]
29
+ return None
30
+
31
+
32
+ def detect_plan_issues(
33
+ plan_json: list[dict],
34
+ original_query: str | None = None,
35
+ ) -> list[Finding]:
36
+ """Analyze an EXPLAIN ANALYZE JSON plan for performance issues."""
37
+ if not plan_json:
38
+ return []
39
+
40
+ findings = []
41
+ plan = plan_json[0].get("Plan", {})
42
+ _walk_plan_node(plan, findings, original_query)
43
+ return findings
44
+
45
+
46
+ def _walk_plan_node(
47
+ node: dict,
48
+ findings: list[Finding],
49
+ original_query: str | None = None,
50
+ ) -> None:
51
+ node_type = node.get("Node Type", "")
52
+ relation = node.get("Relation Name")
53
+ actual_rows = node.get("Actual Rows", 0)
54
+ plan_rows = node.get("Plan Rows", 0)
55
+
56
+ if node_type == "Seq Scan" and actual_rows > 100_000:
57
+ filter_text = node.get("Filter", "")
58
+ findings.append(Finding(
59
+ severity=Severity.HIGH if actual_rows > 1_000_000 else Severity.MEDIUM,
60
+ category=Category.SEQUENTIAL_SCAN,
61
+ table=relation,
62
+ query=original_query,
63
+ detail=(
64
+ f"Sequential scan on '{relation}' reading {actual_rows:,} rows. "
65
+ f"Filter: {filter_text or 'none'}. "
66
+ f"An index on the filtered columns would likely eliminate this scan."
67
+ ),
68
+ estimated_impact=f"Scanning {actual_rows:,} rows instead of targeted index lookup",
69
+ suggested_fix=(
70
+ f"Identify the columns in the WHERE clause and create a targeted index: "
71
+ f"CREATE INDEX CONCURRENTLY ON {relation} (...);"
72
+ if relation else None
73
+ ),
74
+ evidence={
75
+ "node_type": node_type,
76
+ "actual_rows": actual_rows,
77
+ "filter": filter_text,
78
+ "relation": relation,
79
+ },
80
+ ))
81
+
82
+ if plan_rows > 0 and actual_rows > 0:
83
+ estimate_ratio = actual_rows / max(plan_rows, 1)
84
+ if estimate_ratio > 10 or estimate_ratio < 0.1:
85
+ findings.append(Finding(
86
+ severity=Severity.MEDIUM,
87
+ category=Category.STALE_STATS,
88
+ table=relation,
89
+ query=original_query,
90
+ detail=(
91
+ f"Row estimate is off by {estimate_ratio:.1f}x on '{relation or 'unknown'}'. "
92
+ f"Planned: {plan_rows:,}, actual: {actual_rows:,}. "
93
+ f"Table statistics may be stale, causing the planner to pick a bad strategy."
94
+ ),
95
+ suggested_fix=f"ANALYZE {relation};" if relation else "Run ANALYZE on the relevant tables.",
96
+ evidence={
97
+ "planned_rows": plan_rows,
98
+ "actual_rows": actual_rows,
99
+ "estimate_ratio": round(estimate_ratio, 2),
100
+ "relation": relation,
101
+ },
102
+ ))
103
+
104
+ if node_type == "Nested Loop" and actual_rows > 10_000:
105
+ inner = node.get("Plans", [{}])
106
+ inner_type = inner[-1].get("Node Type", "") if inner else ""
107
+ if inner_type == "Seq Scan":
108
+ inner_relation = inner[-1].get("Relation Name", "unknown")
109
+ findings.append(Finding(
110
+ severity=Severity.HIGH,
111
+ category=Category.MISSING_INDEX,
112
+ query=original_query,
113
+ detail=(
114
+ f"Nested loop join with sequential scan on '{inner_relation}' "
115
+ f"processing {actual_rows:,} rows. "
116
+ f"A hash join or index lookup would be faster."
117
+ ),
118
+ estimated_impact="Nested loop + seq scan is the slowest join strategy",
119
+ evidence={
120
+ "outer_type": node_type,
121
+ "inner_type": inner_type,
122
+ "actual_rows": actual_rows,
123
+ "inner_relation": inner_relation,
124
+ },
125
+ ))
126
+
127
+ for child in node.get("Plans", []):
128
+ _walk_plan_node(child, findings, original_query)