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.
Files changed (57) hide show
  1. mcp_server/__init__.py +1 -0
  2. mcp_server/_shared.py +101 -0
  3. mcp_server/server.py +37 -0
  4. mcp_server/tools/__init__.py +1 -0
  5. mcp_server/tools/activity.py +52 -0
  6. mcp_server/tools/analysis.py +89 -0
  7. mcp_server/tools/indexes.py +65 -0
  8. mcp_server/tools/queries.py +60 -0
  9. mcp_server/tools/remediation.py +281 -0
  10. mcp_server/tools/replication.py +43 -0
  11. mcp_server/tools/server.py +86 -0
  12. mcp_server/tools/tables.py +46 -0
  13. postgres_aiops/__init__.py +9 -0
  14. postgres_aiops/cli/__init__.py +9 -0
  15. postgres_aiops/cli/_common.py +78 -0
  16. postgres_aiops/cli/_root.py +68 -0
  17. postgres_aiops/cli/activity.py +52 -0
  18. postgres_aiops/cli/analyze.py +53 -0
  19. postgres_aiops/cli/doctor.py +21 -0
  20. postgres_aiops/cli/index.py +55 -0
  21. postgres_aiops/cli/init.py +112 -0
  22. postgres_aiops/cli/overview.py +16 -0
  23. postgres_aiops/cli/query.py +70 -0
  24. postgres_aiops/cli/remediate.py +186 -0
  25. postgres_aiops/cli/replication.py +45 -0
  26. postgres_aiops/cli/secret.py +103 -0
  27. postgres_aiops/cli/server.py +69 -0
  28. postgres_aiops/cli/table.py +45 -0
  29. postgres_aiops/config.py +154 -0
  30. postgres_aiops/connection.py +178 -0
  31. postgres_aiops/doctor.py +84 -0
  32. postgres_aiops/governance/__init__.py +40 -0
  33. postgres_aiops/governance/audit.py +377 -0
  34. postgres_aiops/governance/budget.py +225 -0
  35. postgres_aiops/governance/decorators.py +474 -0
  36. postgres_aiops/governance/paths.py +23 -0
  37. postgres_aiops/governance/patterns.py +378 -0
  38. postgres_aiops/governance/policy.py +411 -0
  39. postgres_aiops/governance/sanitize.py +39 -0
  40. postgres_aiops/governance/undo.py +218 -0
  41. postgres_aiops/ops/__init__.py +1 -0
  42. postgres_aiops/ops/_util.py +102 -0
  43. postgres_aiops/ops/activity.py +193 -0
  44. postgres_aiops/ops/analysis.py +263 -0
  45. postgres_aiops/ops/indexes.py +211 -0
  46. postgres_aiops/ops/overview.py +51 -0
  47. postgres_aiops/ops/queries.py +123 -0
  48. postgres_aiops/ops/remediation.py +237 -0
  49. postgres_aiops/ops/replication.py +144 -0
  50. postgres_aiops/ops/server.py +151 -0
  51. postgres_aiops/ops/tables.py +146 -0
  52. postgres_aiops/secretstore.py +302 -0
  53. postgres_aiops-0.1.0.dist-info/METADATA +119 -0
  54. postgres_aiops-0.1.0.dist-info/RECORD +57 -0
  55. postgres_aiops-0.1.0.dist-info/WHEEL +4 -0
  56. postgres_aiops-0.1.0.dist-info/entry_points.txt +3 -0
  57. 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
+ }