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.
- pgtriage-0.1.0/.gitignore +14 -0
- pgtriage-0.1.0/LICENSE +21 -0
- pgtriage-0.1.0/PKG-INFO +148 -0
- pgtriage-0.1.0/README.md +124 -0
- pgtriage-0.1.0/pyproject.toml +40 -0
- pgtriage-0.1.0/src/pgtriage/__init__.py +9 -0
- pgtriage-0.1.0/src/pgtriage/__main__.py +3 -0
- pgtriage-0.1.0/src/pgtriage/analyzers/__init__.py +0 -0
- pgtriage-0.1.0/src/pgtriage/analyzers/config_rules.py +159 -0
- pgtriage-0.1.0/src/pgtriage/analyzers/explain.py +128 -0
- pgtriage-0.1.0/src/pgtriage/analyzers/patterns.py +182 -0
- pgtriage-0.1.0/src/pgtriage/collectors/__init__.py +0 -0
- pgtriage-0.1.0/src/pgtriage/collectors/config.py +63 -0
- pgtriage-0.1.0/src/pgtriage/collectors/index_health.py +84 -0
- pgtriage-0.1.0/src/pgtriage/collectors/slow_queries.py +76 -0
- pgtriage-0.1.0/src/pgtriage/collectors/table_health.py +72 -0
- pgtriage-0.1.0/src/pgtriage/connection.py +57 -0
- pgtriage-0.1.0/src/pgtriage/models.py +94 -0
- pgtriage-0.1.0/src/pgtriage/server.py +398 -0
- pgtriage-0.1.0/tests/__init__.py +0 -0
- pgtriage-0.1.0/tests/conftest.py +42 -0
- pgtriage-0.1.0/tests/test_analyzers/__init__.py +0 -0
- pgtriage-0.1.0/tests/test_analyzers/test_config_rules.py +74 -0
- pgtriage-0.1.0/tests/test_analyzers/test_explain.py +67 -0
- pgtriage-0.1.0/tests/test_analyzers/test_patterns.py +93 -0
- pgtriage-0.1.0/tests/test_collectors/__init__.py +0 -0
- pgtriage-0.1.0/tests/test_models.py +45 -0
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.
|
pgtriage-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
pgtriage-0.1.0/README.md
ADDED
|
@@ -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
|
+
]
|
|
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)
|