postgres-aiops 0.1.0__py3-none-any.whl
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.
- mcp_server/__init__.py +1 -0
- mcp_server/_shared.py +101 -0
- mcp_server/server.py +37 -0
- mcp_server/tools/__init__.py +1 -0
- mcp_server/tools/activity.py +52 -0
- mcp_server/tools/analysis.py +89 -0
- mcp_server/tools/indexes.py +65 -0
- mcp_server/tools/queries.py +60 -0
- mcp_server/tools/remediation.py +281 -0
- mcp_server/tools/replication.py +43 -0
- mcp_server/tools/server.py +86 -0
- mcp_server/tools/tables.py +46 -0
- postgres_aiops/__init__.py +9 -0
- postgres_aiops/cli/__init__.py +9 -0
- postgres_aiops/cli/_common.py +78 -0
- postgres_aiops/cli/_root.py +68 -0
- postgres_aiops/cli/activity.py +52 -0
- postgres_aiops/cli/analyze.py +53 -0
- postgres_aiops/cli/doctor.py +21 -0
- postgres_aiops/cli/index.py +55 -0
- postgres_aiops/cli/init.py +112 -0
- postgres_aiops/cli/overview.py +16 -0
- postgres_aiops/cli/query.py +70 -0
- postgres_aiops/cli/remediate.py +186 -0
- postgres_aiops/cli/replication.py +45 -0
- postgres_aiops/cli/secret.py +103 -0
- postgres_aiops/cli/server.py +69 -0
- postgres_aiops/cli/table.py +45 -0
- postgres_aiops/config.py +154 -0
- postgres_aiops/connection.py +178 -0
- postgres_aiops/doctor.py +84 -0
- postgres_aiops/governance/__init__.py +40 -0
- postgres_aiops/governance/audit.py +377 -0
- postgres_aiops/governance/budget.py +225 -0
- postgres_aiops/governance/decorators.py +474 -0
- postgres_aiops/governance/paths.py +23 -0
- postgres_aiops/governance/patterns.py +378 -0
- postgres_aiops/governance/policy.py +411 -0
- postgres_aiops/governance/sanitize.py +39 -0
- postgres_aiops/governance/undo.py +218 -0
- postgres_aiops/ops/__init__.py +1 -0
- postgres_aiops/ops/_util.py +102 -0
- postgres_aiops/ops/activity.py +193 -0
- postgres_aiops/ops/analysis.py +263 -0
- postgres_aiops/ops/indexes.py +211 -0
- postgres_aiops/ops/overview.py +51 -0
- postgres_aiops/ops/queries.py +123 -0
- postgres_aiops/ops/remediation.py +237 -0
- postgres_aiops/ops/replication.py +144 -0
- postgres_aiops/ops/server.py +151 -0
- postgres_aiops/ops/tables.py +146 -0
- postgres_aiops/secretstore.py +302 -0
- postgres_aiops-0.1.0.dist-info/METADATA +119 -0
- postgres_aiops-0.1.0.dist-info/RECORD +57 -0
- postgres_aiops-0.1.0.dist-info/WHEEL +4 -0
- postgres_aiops-0.1.0.dist-info/entry_points.txt +3 -0
- postgres_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Shared helpers for the PostgreSQL ops modules.
|
|
2
|
+
|
|
3
|
+
Two jobs:
|
|
4
|
+
|
|
5
|
+
* ``s`` — pass catalog/query text through the governance ``sanitize`` (bounded
|
|
6
|
+
length, prompt-injection defense) before it reaches an agent.
|
|
7
|
+
* ``qualify`` / ``quote_ident`` — the ONLY sanctioned way to place an
|
|
8
|
+
identifier (schema/table/index/column) into a statement that cannot be
|
|
9
|
+
parameterised (DDL, ``VACUUM``, ``ANALYZE``, ``REINDEX``). Every part is
|
|
10
|
+
validated against strict identifier rules and then double-quoted, so a value
|
|
11
|
+
that is not a plain identifier is rejected rather than interpolated.
|
|
12
|
+
|
|
13
|
+
Values (pids, thresholds, limits, setting values) are ALWAYS passed as query
|
|
14
|
+
parameters — never string-formatted into SQL.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from postgres_aiops.governance import sanitize
|
|
23
|
+
|
|
24
|
+
# A PostgreSQL unquoted identifier component: letter/underscore then
|
|
25
|
+
# letters/digits/underscore/dollar. We deliberately reject everything else
|
|
26
|
+
# (spaces, quotes, semicolons, operators) so interpolation cannot inject SQL.
|
|
27
|
+
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*$")
|
|
28
|
+
|
|
29
|
+
# Whitelist of ORDER-BY columns callers may choose from (maps a friendly name to
|
|
30
|
+
# a real column). Used so an ordering choice is never taken from raw user text.
|
|
31
|
+
STATEMENT_ORDER_COLUMNS = {
|
|
32
|
+
"total_time": "total_exec_time",
|
|
33
|
+
"mean_time": "mean_exec_time",
|
|
34
|
+
"calls": "calls",
|
|
35
|
+
"rows": "rows",
|
|
36
|
+
"io": "shared_blks_read",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def s(value: Any, limit: int = 200) -> str:
|
|
41
|
+
"""Sanitize an arbitrary value to a bounded, injection-safe string."""
|
|
42
|
+
return sanitize(str(value if value is not None else ""), limit)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def quote_ident(part: str) -> str:
|
|
46
|
+
"""Validate a single identifier component and return it double-quoted.
|
|
47
|
+
|
|
48
|
+
Raises ``ValueError`` for anything that is not a plain identifier — this is
|
|
49
|
+
the boundary that makes identifier interpolation safe.
|
|
50
|
+
"""
|
|
51
|
+
if not isinstance(part, str) or not _IDENT_RE.match(part):
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Invalid SQL identifier {part!r}: only letters, digits, underscore "
|
|
54
|
+
f"and '$' are allowed (must start with a letter/underscore)."
|
|
55
|
+
)
|
|
56
|
+
return '"' + part + '"'
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def qualify(name: str) -> str:
|
|
60
|
+
"""Validate + quote a possibly schema-qualified name (``schema.table``).
|
|
61
|
+
|
|
62
|
+
``qualify('public.orders')`` → ``"public"."orders"``; ``qualify('orders')``
|
|
63
|
+
→ ``"orders"``. Each component is validated by :func:`quote_ident`.
|
|
64
|
+
"""
|
|
65
|
+
if not isinstance(name, str) or not name.strip():
|
|
66
|
+
raise ValueError("Empty identifier is not allowed.")
|
|
67
|
+
parts = name.split(".")
|
|
68
|
+
if len(parts) > 2:
|
|
69
|
+
raise ValueError(f"Too many name parts in {name!r} (expected schema.table).")
|
|
70
|
+
return ".".join(quote_ident(p) for p in parts)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def quote_literal(value: str) -> str:
|
|
74
|
+
"""Quote a string as a SQL literal (single-quoted, doubling embedded quotes).
|
|
75
|
+
|
|
76
|
+
Only used for ``ALTER SYSTEM SET`` where the value cannot be parameterised.
|
|
77
|
+
"""
|
|
78
|
+
return "'" + str(value).replace("'", "''") + "'"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def order_column(choice: str) -> str:
|
|
82
|
+
"""Map a caller's order-by choice to a real column via the whitelist."""
|
|
83
|
+
col = STATEMENT_ORDER_COLUMNS.get(choice)
|
|
84
|
+
if col is None:
|
|
85
|
+
allowed = ", ".join(sorted(STATEMENT_ORDER_COLUMNS))
|
|
86
|
+
raise ValueError(f"Unknown order_by '{choice}'. Allowed: {allowed}.")
|
|
87
|
+
return col
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def human_bytes(n: Any) -> str:
|
|
91
|
+
"""Render a byte count as a human string (e.g. 1536 -> '1.5 kB')."""
|
|
92
|
+
try:
|
|
93
|
+
size = float(n)
|
|
94
|
+
except (TypeError, ValueError):
|
|
95
|
+
return "0 bytes"
|
|
96
|
+
for unit in ("bytes", "kB", "MB", "GB", "TB"):
|
|
97
|
+
if abs(size) < 1024.0 or unit == "TB":
|
|
98
|
+
if unit == "bytes":
|
|
99
|
+
return f"{int(size)} {unit}"
|
|
100
|
+
return f"{size:.1f} {unit}"
|
|
101
|
+
size /= 1024.0
|
|
102
|
+
return f"{size:.1f} TB"
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Activity reads: sessions, long-running queries, locks and blocking pairs.
|
|
2
|
+
|
|
3
|
+
Every query surfaced to an agent is truncated in SQL (``left(query, N)``) and
|
|
4
|
+
sanitised again via ``s`` — a running statement can contain arbitrary text.
|
|
5
|
+
Thresholds (seconds) are passed as bound parameters, never string-formatted.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from postgres_aiops.ops._util import s
|
|
13
|
+
|
|
14
|
+
_ACTIVITY_SQL = """
|
|
15
|
+
SELECT pid,
|
|
16
|
+
usename AS username,
|
|
17
|
+
datname AS database,
|
|
18
|
+
client_addr::text AS client_addr,
|
|
19
|
+
application_name,
|
|
20
|
+
state,
|
|
21
|
+
wait_event_type,
|
|
22
|
+
wait_event,
|
|
23
|
+
backend_type,
|
|
24
|
+
xact_start,
|
|
25
|
+
query_start,
|
|
26
|
+
state_change,
|
|
27
|
+
EXTRACT(EPOCH FROM (now() - query_start))::int AS query_age_seconds,
|
|
28
|
+
EXTRACT(EPOCH FROM (now() - xact_start))::int AS xact_age_seconds,
|
|
29
|
+
left(query, 500) AS query
|
|
30
|
+
FROM pg_stat_activity
|
|
31
|
+
WHERE pid <> pg_backend_pid()
|
|
32
|
+
AND (%(state)s IS NULL OR state = %(state)s)
|
|
33
|
+
AND (%(include_idle)s OR state IS DISTINCT FROM 'idle')
|
|
34
|
+
ORDER BY query_start NULLS LAST
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
_LONG_RUNNING_SQL = """
|
|
38
|
+
SELECT pid, usename AS username, datname AS database, state,
|
|
39
|
+
wait_event_type, wait_event,
|
|
40
|
+
EXTRACT(EPOCH FROM (now() - query_start))::int AS duration_seconds,
|
|
41
|
+
(now() - query_start) AS duration,
|
|
42
|
+
left(query, 500) AS query
|
|
43
|
+
FROM pg_stat_activity
|
|
44
|
+
WHERE state <> 'idle'
|
|
45
|
+
AND query_start IS NOT NULL
|
|
46
|
+
AND (now() - query_start) >= make_interval(secs => %(min_seconds)s)
|
|
47
|
+
AND pid <> pg_backend_pid()
|
|
48
|
+
ORDER BY query_start ASC
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
_LOCKS_SQL = """
|
|
52
|
+
SELECT a.pid, a.usename AS username, a.datname AS database,
|
|
53
|
+
l.locktype, l.mode, l.granted,
|
|
54
|
+
COALESCE(c.relname, l.locktype) AS object,
|
|
55
|
+
a.wait_event_type, a.wait_event,
|
|
56
|
+
left(a.query, 300) AS query
|
|
57
|
+
FROM pg_locks l
|
|
58
|
+
JOIN pg_stat_activity a ON a.pid = l.pid
|
|
59
|
+
LEFT JOIN pg_class c ON c.oid = l.relation
|
|
60
|
+
WHERE a.pid <> pg_backend_pid()
|
|
61
|
+
ORDER BY l.granted, a.pid
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
# One row per (blocked backend -> a backend blocking it). pg_blocking_pids gives
|
|
65
|
+
# the exact wait-for edges; unnesting produces the graph the flagship RCA walks.
|
|
66
|
+
_BLOCKING_PAIRS_SQL = """
|
|
67
|
+
SELECT blocked.pid AS blocked_pid,
|
|
68
|
+
blocked.usename AS blocked_user,
|
|
69
|
+
blocked.datname AS database,
|
|
70
|
+
blocked.wait_event_type,
|
|
71
|
+
blocked.wait_event,
|
|
72
|
+
left(blocked.query, 300) AS blocked_query,
|
|
73
|
+
blocking.pid AS blocking_pid,
|
|
74
|
+
blocking.usename AS blocking_user,
|
|
75
|
+
blocking.state AS blocking_state,
|
|
76
|
+
left(blocking.query, 300) AS blocking_query
|
|
77
|
+
FROM pg_stat_activity blocked
|
|
78
|
+
JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS bp(pid) ON true
|
|
79
|
+
JOIN pg_stat_activity blocking ON blocking.pid = bp.pid
|
|
80
|
+
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _activity_row(r: dict) -> dict:
|
|
85
|
+
return {
|
|
86
|
+
"pid": r.get("pid"),
|
|
87
|
+
"username": s(r.get("username"), 128),
|
|
88
|
+
"database": s(r.get("database"), 128),
|
|
89
|
+
"clientAddr": s(r.get("client_addr"), 64),
|
|
90
|
+
"applicationName": s(r.get("application_name"), 128),
|
|
91
|
+
"state": s(r.get("state"), 64),
|
|
92
|
+
"waitEventType": s(r.get("wait_event_type"), 64),
|
|
93
|
+
"waitEvent": s(r.get("wait_event"), 64),
|
|
94
|
+
"backendType": s(r.get("backend_type"), 64),
|
|
95
|
+
"queryAgeSeconds": r.get("query_age_seconds"),
|
|
96
|
+
"xactAgeSeconds": r.get("xact_age_seconds"),
|
|
97
|
+
"query": s(r.get("query"), 500),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def list_activity(
|
|
102
|
+
conn: Any, state: str | None = None, include_idle: bool = True
|
|
103
|
+
) -> dict:
|
|
104
|
+
"""[READ] Current sessions from pg_stat_activity, with per-state counts.
|
|
105
|
+
|
|
106
|
+
Flags idle-in-transaction backends (an open transaction holding resources).
|
|
107
|
+
"""
|
|
108
|
+
rows = conn.query(_ACTIVITY_SQL, {"state": state, "include_idle": include_idle})
|
|
109
|
+
sessions = [_activity_row(r) for r in rows]
|
|
110
|
+
by_state: dict[str, int] = {}
|
|
111
|
+
for r in sessions:
|
|
112
|
+
key = r["state"] or "unknown"
|
|
113
|
+
by_state[key] = by_state.get(key, 0) + 1
|
|
114
|
+
idle_in_txn = [
|
|
115
|
+
r for r in sessions if r["state"] == "idle in transaction"
|
|
116
|
+
]
|
|
117
|
+
return {
|
|
118
|
+
"total": len(sessions),
|
|
119
|
+
"byState": dict(sorted(by_state.items(), key=lambda kv: kv[1], reverse=True)),
|
|
120
|
+
"idleInTransactionCount": len(idle_in_txn),
|
|
121
|
+
"idleInTransaction": idle_in_txn,
|
|
122
|
+
"sessions": sessions,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def long_running_queries(conn: Any, min_seconds: int = 60) -> dict:
|
|
127
|
+
"""[READ] Active queries running at least ``min_seconds``, oldest first."""
|
|
128
|
+
rows = conn.query(_LONG_RUNNING_SQL, {"min_seconds": int(min_seconds)})
|
|
129
|
+
queries = [
|
|
130
|
+
{
|
|
131
|
+
"pid": r.get("pid"),
|
|
132
|
+
"username": s(r.get("username"), 128),
|
|
133
|
+
"database": s(r.get("database"), 128),
|
|
134
|
+
"state": s(r.get("state"), 64),
|
|
135
|
+
"durationSeconds": r.get("duration_seconds"),
|
|
136
|
+
"duration": s(r.get("duration"), 64),
|
|
137
|
+
"waitEventType": s(r.get("wait_event_type"), 64),
|
|
138
|
+
"waitEvent": s(r.get("wait_event"), 64),
|
|
139
|
+
"query": s(r.get("query"), 500),
|
|
140
|
+
}
|
|
141
|
+
for r in rows
|
|
142
|
+
]
|
|
143
|
+
return {
|
|
144
|
+
"thresholdSeconds": int(min_seconds),
|
|
145
|
+
"count": len(queries),
|
|
146
|
+
"queries": queries,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def list_locks(conn: Any) -> dict:
|
|
151
|
+
"""[READ] Held/awaited locks joined to their owning backend and object."""
|
|
152
|
+
rows = conn.query(_LOCKS_SQL)
|
|
153
|
+
locks = [
|
|
154
|
+
{
|
|
155
|
+
"pid": r.get("pid"),
|
|
156
|
+
"username": s(r.get("username"), 128),
|
|
157
|
+
"database": s(r.get("database"), 128),
|
|
158
|
+
"lockType": s(r.get("locktype"), 64),
|
|
159
|
+
"mode": s(r.get("mode"), 64),
|
|
160
|
+
"granted": bool(r.get("granted")),
|
|
161
|
+
"object": s(r.get("object"), 128),
|
|
162
|
+
"waitEventType": s(r.get("wait_event_type"), 64),
|
|
163
|
+
"query": s(r.get("query"), 300),
|
|
164
|
+
}
|
|
165
|
+
for r in rows
|
|
166
|
+
]
|
|
167
|
+
waiting = [lock for lock in locks if not lock["granted"]]
|
|
168
|
+
return {
|
|
169
|
+
"total": len(locks),
|
|
170
|
+
"waitingCount": len(waiting),
|
|
171
|
+
"waiting": waiting,
|
|
172
|
+
"locks": locks,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def blocking_pairs(conn: Any) -> list[dict]:
|
|
177
|
+
"""[READ] Wait-for edges (blocked pid -> blocking pid) from pg_blocking_pids."""
|
|
178
|
+
rows = conn.query(_BLOCKING_PAIRS_SQL)
|
|
179
|
+
return [
|
|
180
|
+
{
|
|
181
|
+
"blockedPid": r.get("blocked_pid"),
|
|
182
|
+
"blockedUser": s(r.get("blocked_user"), 128),
|
|
183
|
+
"database": s(r.get("database"), 128),
|
|
184
|
+
"waitEventType": s(r.get("wait_event_type"), 64),
|
|
185
|
+
"waitEvent": s(r.get("wait_event"), 64),
|
|
186
|
+
"blockedQuery": s(r.get("blocked_query"), 300),
|
|
187
|
+
"blockingPid": r.get("blocking_pid"),
|
|
188
|
+
"blockingUser": s(r.get("blocking_user"), 128),
|
|
189
|
+
"blockingState": s(r.get("blocking_state"), 64),
|
|
190
|
+
"blockingQuery": s(r.get("blocking_query"), 300),
|
|
191
|
+
}
|
|
192
|
+
for r in rows
|
|
193
|
+
]
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Flagship signature analyses over PostgreSQL telemetry (pure analysis).
|
|
2
|
+
|
|
3
|
+
The differentiators — transparent heuristics, every flag reported with its number
|
|
4
|
+
so a DBA can see *why* something was ranked, never a black-box verdict:
|
|
5
|
+
|
|
6
|
+
1. ``slow_query_rca`` — take the worst pg_stat_statements entry (+ an optional
|
|
7
|
+
EXPLAIN plan) and map it to a likely cause + concrete action.
|
|
8
|
+
2. ``bloat_and_vacuum_analysis`` — combine dead-tuple ratio, table size and
|
|
9
|
+
autovacuum recency into a per-table recommendation.
|
|
10
|
+
3. ``blocking_lock_chain_rca`` — build the wait-for tree from blocking pairs,
|
|
11
|
+
name the root blocker, and give the action.
|
|
12
|
+
|
|
13
|
+
All three are pure functions (no I/O): pass them telemetry (from the reads in the
|
|
14
|
+
other ops modules, or injected) and they return the analysis.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
MAX_ROWS = 100
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ── 1. slow query RCA ───────────────────────────────────────────────────────
|
|
25
|
+
# Thresholds that flip a signal on (each reported with its measured number).
|
|
26
|
+
_SLOW_MEAN_MS = 100.0
|
|
27
|
+
_LOW_CACHE_HIT_PCT = 95.0
|
|
28
|
+
_HIGH_CALLS = 100_000
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _plan_node_types(plan: Any, found: set[str]) -> None:
|
|
32
|
+
"""Recursively collect ``Node Type`` values from an EXPLAIN JSON plan."""
|
|
33
|
+
if isinstance(plan, dict):
|
|
34
|
+
node = plan.get("Node Type")
|
|
35
|
+
if isinstance(node, str):
|
|
36
|
+
found.add(node)
|
|
37
|
+
for value in plan.values():
|
|
38
|
+
_plan_node_types(value, found)
|
|
39
|
+
elif isinstance(plan, list):
|
|
40
|
+
for item in plan:
|
|
41
|
+
_plan_node_types(item, found)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _slow_findings(worst: dict, node_types: set[str]) -> list[dict]:
|
|
45
|
+
"""Build the list of cited findings (cause + action) for the worst statement."""
|
|
46
|
+
findings: list[dict] = []
|
|
47
|
+
mean = float(worst.get("meanExecTimeMs") or 0)
|
|
48
|
+
cache = worst.get("cacheHitRatioPct")
|
|
49
|
+
temp_written = worst.get("tempBlksWritten") or 0
|
|
50
|
+
calls = worst.get("calls") or 0
|
|
51
|
+
|
|
52
|
+
if "Seq Scan" in node_types and mean >= _SLOW_MEAN_MS:
|
|
53
|
+
findings.append({
|
|
54
|
+
"signal": "sequential scan on a slow statement",
|
|
55
|
+
"detail": f"plan contains Seq Scan; mean {mean}ms >= {_SLOW_MEAN_MS}ms",
|
|
56
|
+
"cause": "A hot query scans a table with no usable index.",
|
|
57
|
+
"action": "Add an index on the filter/join columns; confirm with EXPLAIN.",
|
|
58
|
+
})
|
|
59
|
+
if isinstance(cache, (int, float)) and cache < _LOW_CACHE_HIT_PCT:
|
|
60
|
+
findings.append({
|
|
61
|
+
"signal": "low shared-buffer cache hit ratio",
|
|
62
|
+
"detail": f"cacheHitRatioPct {cache}% < {_LOW_CACHE_HIT_PCT}%",
|
|
63
|
+
"cause": "Reads miss the buffer cache and hit disk repeatedly.",
|
|
64
|
+
"action": "Add a covering index to cut blocks read, or raise shared_buffers.",
|
|
65
|
+
})
|
|
66
|
+
if temp_written and int(temp_written) > 0:
|
|
67
|
+
findings.append({
|
|
68
|
+
"signal": "temp blocks written (spill to disk)",
|
|
69
|
+
"detail": f"tempBlksWritten={temp_written} — sorts/hashes spilled",
|
|
70
|
+
"cause": "work_mem is too small for this query's sort/hash.",
|
|
71
|
+
"action": "Raise work_mem for the session/role, or reduce the sorted set.",
|
|
72
|
+
})
|
|
73
|
+
if calls and int(calls) >= _HIGH_CALLS:
|
|
74
|
+
findings.append({
|
|
75
|
+
"signal": "very high call count",
|
|
76
|
+
"detail": f"calls={calls} >= {_HIGH_CALLS}",
|
|
77
|
+
"cause": "A cheap statement is executed enormously often (possible N+1).",
|
|
78
|
+
"action": "Batch/cache at the application, or use a prepared/set-based query.",
|
|
79
|
+
})
|
|
80
|
+
if not findings:
|
|
81
|
+
findings.append({
|
|
82
|
+
"signal": "no dominant signal",
|
|
83
|
+
"detail": f"mean {mean}ms, calls {calls}",
|
|
84
|
+
"cause": "The statement is costly but shows no single clear driver.",
|
|
85
|
+
"action": "EXPLAIN (ANALYZE, BUFFERS) it and inspect the most expensive node.",
|
|
86
|
+
})
|
|
87
|
+
return findings
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def slow_query_rca(statements: list[dict], explain: dict | None = None) -> dict:
|
|
91
|
+
"""[READ] RCA for the worst pg_stat_statements entry (+ optional EXPLAIN plan).
|
|
92
|
+
|
|
93
|
+
Picks the statement with the greatest total execution time, then maps its
|
|
94
|
+
numbers (mean time, cache-hit ratio, temp spill, call count) — and any
|
|
95
|
+
EXPLAIN plan node types supplied — to cited causes and concrete actions.
|
|
96
|
+
"""
|
|
97
|
+
ranked = sorted(
|
|
98
|
+
(s for s in (statements or []) if isinstance(s, dict)),
|
|
99
|
+
key=lambda x: float(x.get("totalExecTimeMs") or 0),
|
|
100
|
+
reverse=True,
|
|
101
|
+
)
|
|
102
|
+
if not ranked:
|
|
103
|
+
return {"evaluated": 0, "worst": None, "findings": [], "note": "No statements supplied."}
|
|
104
|
+
|
|
105
|
+
worst = ranked[0]
|
|
106
|
+
node_types: set[str] = set()
|
|
107
|
+
if explain:
|
|
108
|
+
_plan_node_types(explain.get("plan", explain), node_types)
|
|
109
|
+
findings = _slow_findings(worst, node_types)
|
|
110
|
+
return {
|
|
111
|
+
"evaluated": len(ranked),
|
|
112
|
+
"worst": {
|
|
113
|
+
"queryId": worst.get("queryId"),
|
|
114
|
+
"query": worst.get("query"),
|
|
115
|
+
"calls": worst.get("calls"),
|
|
116
|
+
"totalExecTimeMs": worst.get("totalExecTimeMs"),
|
|
117
|
+
"meanExecTimeMs": worst.get("meanExecTimeMs"),
|
|
118
|
+
"cacheHitRatioPct": worst.get("cacheHitRatioPct"),
|
|
119
|
+
"tempBlksWritten": worst.get("tempBlksWritten"),
|
|
120
|
+
},
|
|
121
|
+
"planNodeTypes": sorted(node_types),
|
|
122
|
+
"findings": findings,
|
|
123
|
+
"note": (
|
|
124
|
+
"Advisory read-only heuristic over pg_stat_statements; every finding "
|
|
125
|
+
"cites the measured number. Worst = greatest total execution time."
|
|
126
|
+
),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ── 2. bloat & vacuum analysis ──────────────────────────────────────────────
|
|
131
|
+
_DEAD_PCT_WARN = 20.0
|
|
132
|
+
_DEAD_TUP_WARN = 1000
|
|
133
|
+
_MOD_ANALYZE_WARN = 50_000
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _vacuum_recommendation(row: dict) -> dict | None:
|
|
137
|
+
"""Return a cited recommendation for one table, or None if it looks healthy."""
|
|
138
|
+
dead_pct = float(row.get("deadPct") or 0)
|
|
139
|
+
dead = int(row.get("deadTuples") or 0)
|
|
140
|
+
last_autovac = row.get("lastAutovacuum")
|
|
141
|
+
reasons: list[str] = []
|
|
142
|
+
action = None
|
|
143
|
+
if dead_pct >= _DEAD_PCT_WARN and dead >= _DEAD_TUP_WARN:
|
|
144
|
+
reasons.append(f"deadPct {dead_pct}% >= {_DEAD_PCT_WARN}% ({dead} dead tuples)")
|
|
145
|
+
action = "Run VACUUM (ANALYZE); if disk must be reclaimed, VACUUM FULL/pg_repack."
|
|
146
|
+
if not last_autovac and dead >= _DEAD_TUP_WARN:
|
|
147
|
+
reasons.append(f"never autovacuumed but has {dead} dead tuples")
|
|
148
|
+
action = action or "Lower autovacuum_vacuum_scale_factor for this table."
|
|
149
|
+
if not reasons:
|
|
150
|
+
return None
|
|
151
|
+
return {
|
|
152
|
+
"schema": row.get("schema"),
|
|
153
|
+
"table": row.get("table"),
|
|
154
|
+
"deadPct": dead_pct,
|
|
155
|
+
"deadTuples": dead,
|
|
156
|
+
"sizePretty": row.get("sizePretty"),
|
|
157
|
+
"lastAutovacuum": last_autovac,
|
|
158
|
+
"reasons": reasons,
|
|
159
|
+
"action": action,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def bloat_and_vacuum_analysis(tables: list[dict]) -> dict:
|
|
164
|
+
"""[READ] Rank tables needing vacuum from dead-tuple ratio + autovacuum recency.
|
|
165
|
+
|
|
166
|
+
Pure analysis over table-bloat rows ({schema, table, deadPct, deadTuples,
|
|
167
|
+
sizePretty, lastAutovacuum}). Each recommendation cites the numbers that
|
|
168
|
+
triggered it; healthy tables are omitted.
|
|
169
|
+
"""
|
|
170
|
+
recs = [rec for rec in (_vacuum_recommendation(r) for r in (tables or [])) if rec]
|
|
171
|
+
recs.sort(key=lambda r: r["deadPct"], reverse=True)
|
|
172
|
+
return {
|
|
173
|
+
"tablesEvaluated": len(tables or []),
|
|
174
|
+
"needsAttentionCount": len(recs),
|
|
175
|
+
"thresholds": {"deadPct": _DEAD_PCT_WARN, "deadTuples": _DEAD_TUP_WARN},
|
|
176
|
+
"recommendations": recs[:MAX_ROWS],
|
|
177
|
+
"note": (
|
|
178
|
+
"Advisory read-only heuristic: flags deadPct >= "
|
|
179
|
+
f"{_DEAD_PCT_WARN}% with >= {_DEAD_TUP_WARN} dead tuples, or a table "
|
|
180
|
+
"never autovacuumed that has accumulated dead tuples."
|
|
181
|
+
),
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ── 3. blocking lock chain RCA ──────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _descendants(root: int, children: dict[int, list[int]]) -> set[int]:
|
|
189
|
+
"""All pids transitively blocked by ``root`` (BFS, cycle-safe)."""
|
|
190
|
+
seen: set[int] = set()
|
|
191
|
+
stack = list(children.get(root, []))
|
|
192
|
+
while stack:
|
|
193
|
+
pid = stack.pop()
|
|
194
|
+
if pid in seen:
|
|
195
|
+
continue
|
|
196
|
+
seen.add(pid)
|
|
197
|
+
stack.extend(children.get(pid, []))
|
|
198
|
+
return seen
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def blocking_lock_chain_rca(pairs: list[dict]) -> dict:
|
|
202
|
+
"""[READ] Build the wait-for tree from blocking pairs and name the root blocker.
|
|
203
|
+
|
|
204
|
+
Pure analysis over pairs ({blockedPid, blockingPid, blockedQuery,
|
|
205
|
+
blockingQuery, ...}). A root blocker blocks others but is itself blocked by
|
|
206
|
+
none; the worst root is the one with the most transitively-blocked backends.
|
|
207
|
+
A cycle (everyone blocked) is reported as a likely deadlock.
|
|
208
|
+
"""
|
|
209
|
+
edges = [
|
|
210
|
+
p for p in (pairs or [])
|
|
211
|
+
if isinstance(p, dict) and p.get("blockedPid") and p.get("blockingPid")
|
|
212
|
+
]
|
|
213
|
+
if not edges:
|
|
214
|
+
return {"blockedBackends": 0, "roots": [], "note": "No blocking detected."}
|
|
215
|
+
|
|
216
|
+
children: dict[int, list[int]] = {}
|
|
217
|
+
blocking_query: dict[int, str] = {}
|
|
218
|
+
blocked_pids: set[int] = set()
|
|
219
|
+
blocking_pids: set[int] = set()
|
|
220
|
+
for e in edges:
|
|
221
|
+
b, g = e["blockedPid"], e["blockingPid"]
|
|
222
|
+
children.setdefault(g, []).append(b)
|
|
223
|
+
blocked_pids.add(b)
|
|
224
|
+
blocking_pids.add(g)
|
|
225
|
+
blocking_query.setdefault(g, e.get("blockingQuery") or "")
|
|
226
|
+
|
|
227
|
+
root_pids = [pid for pid in blocking_pids if pid not in blocked_pids]
|
|
228
|
+
if not root_pids:
|
|
229
|
+
return {
|
|
230
|
+
"blockedBackends": len(blocked_pids),
|
|
231
|
+
"roots": [],
|
|
232
|
+
"deadlockSuspected": True,
|
|
233
|
+
"note": (
|
|
234
|
+
"Every blocker is itself blocked — a cycle / possible deadlock. "
|
|
235
|
+
"Inspect the transactions and terminate one to break the cycle."
|
|
236
|
+
),
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
roots = []
|
|
240
|
+
for pid in root_pids:
|
|
241
|
+
blocked = _descendants(pid, children)
|
|
242
|
+
roots.append({
|
|
243
|
+
"rootPid": pid,
|
|
244
|
+
"blockedCount": len(blocked),
|
|
245
|
+
"blockedPids": sorted(blocked),
|
|
246
|
+
"rootQuery": blocking_query.get(pid, ""),
|
|
247
|
+
"action": (
|
|
248
|
+
f"Backend {pid} is the head of the chain — investigate its "
|
|
249
|
+
"transaction; cancel_query(pid) to stop its statement, or "
|
|
250
|
+
"terminate_backend(pid) to end the session and release the locks."
|
|
251
|
+
),
|
|
252
|
+
})
|
|
253
|
+
roots.sort(key=lambda r: r["blockedCount"], reverse=True)
|
|
254
|
+
return {
|
|
255
|
+
"blockedBackends": len(blocked_pids),
|
|
256
|
+
"rootCount": len(roots),
|
|
257
|
+
"worstRootPid": roots[0]["rootPid"],
|
|
258
|
+
"roots": roots[:MAX_ROWS],
|
|
259
|
+
"note": (
|
|
260
|
+
"Advisory read-only heuristic: a root blocker holds locks others wait "
|
|
261
|
+
"on but waits on nobody; worst root blocks the most backends."
|
|
262
|
+
),
|
|
263
|
+
}
|