postgres-mcp-jacob 0.3.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.
- postgres_mcp/__init__.py +24 -0
- postgres_mcp/artifacts.py +323 -0
- postgres_mcp/database_health/__init__.py +4 -0
- postgres_mcp/database_health/buffer_health_calc.py +62 -0
- postgres_mcp/database_health/connection_health_calc.py +76 -0
- postgres_mcp/database_health/constraint_health_calc.py +101 -0
- postgres_mcp/database_health/database_health.py +98 -0
- postgres_mcp/database_health/index_health_calc.py +345 -0
- postgres_mcp/database_health/replication_calc.py +165 -0
- postgres_mcp/database_health/sequence_health_calc.py +164 -0
- postgres_mcp/database_health/vacuum_health_calc.py +102 -0
- postgres_mcp/explain/README.md +26 -0
- postgres_mcp/explain/__init__.py +7 -0
- postgres_mcp/explain/explain_plan.py +241 -0
- postgres_mcp/index/dta_calc.py +837 -0
- postgres_mcp/index/index_opt_base.py +671 -0
- postgres_mcp/index/llm_opt.py +384 -0
- postgres_mcp/index/presentation.py +266 -0
- postgres_mcp/server.py +767 -0
- postgres_mcp/sql/__init__.py +31 -0
- postgres_mcp/sql/bind_params.py +816 -0
- postgres_mcp/sql/extension_utils.py +246 -0
- postgres_mcp/sql/index.py +52 -0
- postgres_mcp/sql/safe_sql.py +1036 -0
- postgres_mcp/sql/sql_driver.py +272 -0
- postgres_mcp/top_queries/__init__.py +4 -0
- postgres_mcp/top_queries/top_queries_calc.py +249 -0
- postgres_mcp_jacob-0.3.0.dist-info/METADATA +668 -0
- postgres_mcp_jacob-0.3.0.dist-info/RECORD +32 -0
- postgres_mcp_jacob-0.3.0.dist-info/WHEEL +4 -0
- postgres_mcp_jacob-0.3.0.dist-info/entry_points.txt +2 -0
- postgres_mcp_jacob-0.3.0.dist-info/licenses/LICENSE +21 -0
postgres_mcp/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from . import server
|
|
5
|
+
from . import top_queries
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
"""Main entry point for the package."""
|
|
10
|
+
# As of version 3.3.0 Psycopg on Windows is not compatible with the default
|
|
11
|
+
# ProactorEventLoop.
|
|
12
|
+
# See: https://www.psycopg.org/psycopg3/docs/advanced/async.html#async
|
|
13
|
+
if sys.platform == "win32":
|
|
14
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
15
|
+
|
|
16
|
+
asyncio.run(server.main())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Optionally expose other important items at package level
|
|
20
|
+
__all__ = [
|
|
21
|
+
"main",
|
|
22
|
+
"server",
|
|
23
|
+
"top_queries",
|
|
24
|
+
]
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Artifacts for the Database Tuning Advisor."""
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from attrs import define
|
|
8
|
+
from attrs import field
|
|
9
|
+
|
|
10
|
+
# If the recommendation cost is 0.0, we can't calculate the improvement multiple.
|
|
11
|
+
# Return 1000000.0 to indicate infinite improvement.
|
|
12
|
+
INFINITE_IMPROVEMENT_MULTIPLIER = 1000000.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ErrorResult:
|
|
16
|
+
"""Simple error result class."""
|
|
17
|
+
|
|
18
|
+
def to_text(self) -> str:
|
|
19
|
+
return self.value
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str):
|
|
22
|
+
self.value = message
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def calculate_improvement_multiple(base_cost: float, rec_cost: float) -> float:
|
|
26
|
+
"""Calculate the improvement multiple from this recommendation."""
|
|
27
|
+
if base_cost <= 0.0:
|
|
28
|
+
# base_cost or rec_cost might be zero, but as they are floats, the might be
|
|
29
|
+
# represented as -0.0. That's why we compare to <= 0.0.
|
|
30
|
+
return 1.0
|
|
31
|
+
if rec_cost <= 0.0:
|
|
32
|
+
# If the recommendation cost is 0.0, we can't calculate the improvement multiple.
|
|
33
|
+
# Return INFINITE_IMPROVEMENT_MULTIPLIER to indicate infinite improvement.
|
|
34
|
+
return INFINITE_IMPROVEMENT_MULTIPLIER
|
|
35
|
+
return base_cost / rec_cost
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@define
|
|
39
|
+
class PlanNode:
|
|
40
|
+
node_type: str
|
|
41
|
+
total_cost: float
|
|
42
|
+
startup_cost: float
|
|
43
|
+
plan_rows: int
|
|
44
|
+
plan_width: int
|
|
45
|
+
|
|
46
|
+
# Actual metrics from ANALYZE
|
|
47
|
+
actual_total_time: float | None = field(default=None)
|
|
48
|
+
actual_startup_time: float | None = field(default=None)
|
|
49
|
+
actual_rows: int | None = field(default=None)
|
|
50
|
+
actual_loops: int | None = field(default=None)
|
|
51
|
+
|
|
52
|
+
# Buffer info
|
|
53
|
+
shared_hit_blocks: int | None = field(default=None)
|
|
54
|
+
shared_read_blocks: int | None = field(default=None)
|
|
55
|
+
shared_written_blocks: int | None = field(default=None)
|
|
56
|
+
|
|
57
|
+
# Other common fields
|
|
58
|
+
relation_name: str | None = field(default=None)
|
|
59
|
+
filter: str | None = field(default=None)
|
|
60
|
+
children: list["PlanNode"] = field(factory=list)
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_json_data(cls, json_node: dict[str, Any]) -> "PlanNode":
|
|
64
|
+
# Extract basic fields
|
|
65
|
+
node = cls(
|
|
66
|
+
node_type=json_node["Node Type"],
|
|
67
|
+
total_cost=json_node["Total Cost"],
|
|
68
|
+
startup_cost=json_node["Startup Cost"],
|
|
69
|
+
plan_rows=json_node["Plan Rows"],
|
|
70
|
+
plan_width=json_node["Plan Width"],
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Optional ANALYZE fields
|
|
74
|
+
if "Actual Total Time" in json_node:
|
|
75
|
+
node.actual_total_time = json_node["Actual Total Time"]
|
|
76
|
+
node.actual_startup_time = json_node["Actual Startup Time"]
|
|
77
|
+
node.actual_rows = json_node["Actual Rows"]
|
|
78
|
+
node.actual_loops = json_node["Actual Loops"]
|
|
79
|
+
|
|
80
|
+
# Optional BUFFERS fields
|
|
81
|
+
if "Shared Hit Blocks" in json_node:
|
|
82
|
+
node.shared_hit_blocks = json_node["Shared Hit Blocks"]
|
|
83
|
+
node.shared_read_blocks = json_node["Shared Read Blocks"]
|
|
84
|
+
node.shared_written_blocks = json_node["Shared Written Blocks"]
|
|
85
|
+
|
|
86
|
+
# Common optional fields
|
|
87
|
+
if "Relation Name" in json_node:
|
|
88
|
+
node.relation_name = json_node["Relation Name"]
|
|
89
|
+
if "Filter" in json_node:
|
|
90
|
+
node.filter = json_node["Filter"]
|
|
91
|
+
|
|
92
|
+
# Recursively process child plans
|
|
93
|
+
if "Plans" in json_node:
|
|
94
|
+
node.children = [cls.from_json_data(child) for child in json_node["Plans"]]
|
|
95
|
+
|
|
96
|
+
return node
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@define
|
|
100
|
+
class ExplainPlanArtifact:
|
|
101
|
+
value: str
|
|
102
|
+
plan_tree: PlanNode
|
|
103
|
+
planning_time: float | None = field(default=None)
|
|
104
|
+
execution_time: float | None = field(default=None)
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
value: str,
|
|
109
|
+
plan_tree: PlanNode,
|
|
110
|
+
planning_time: float | None = None,
|
|
111
|
+
execution_time: float | None = None,
|
|
112
|
+
):
|
|
113
|
+
self.value = value
|
|
114
|
+
self.plan_tree = plan_tree
|
|
115
|
+
self.planning_time = planning_time
|
|
116
|
+
self.execution_time = execution_time
|
|
117
|
+
|
|
118
|
+
def to_text(self) -> str:
|
|
119
|
+
"""Convert the explain plan to a text representation.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
str: A string representation of the execution plan with timing information.
|
|
123
|
+
"""
|
|
124
|
+
result = []
|
|
125
|
+
|
|
126
|
+
# Add timing information if available
|
|
127
|
+
if self.planning_time is not None:
|
|
128
|
+
result.append(f"Planning Time: {self.planning_time:.3f} ms")
|
|
129
|
+
if self.execution_time is not None:
|
|
130
|
+
result.append(f"Execution Time: {self.execution_time:.3f} ms")
|
|
131
|
+
|
|
132
|
+
# Add plan tree representation
|
|
133
|
+
result.append(self._format_plan_node(self.plan_tree))
|
|
134
|
+
|
|
135
|
+
return "\n".join(result)
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _format_plan_node(node: PlanNode, level: int = 0) -> str:
|
|
139
|
+
"""Recursively format a plan node and its children.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
node: The plan node to format
|
|
143
|
+
level: The current indentation level
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
str: A formatted string representation of the node and its children
|
|
147
|
+
"""
|
|
148
|
+
indent = " " * level
|
|
149
|
+
output = f"{indent}→ {node.node_type} (Cost: {node.startup_cost:.2f}..{node.total_cost:.2f})"
|
|
150
|
+
|
|
151
|
+
# Add table name if present
|
|
152
|
+
if node.relation_name:
|
|
153
|
+
output += f" on {node.relation_name}"
|
|
154
|
+
|
|
155
|
+
# Add rows information
|
|
156
|
+
output += f" [Rows: {node.plan_rows}]"
|
|
157
|
+
|
|
158
|
+
# Add actual metrics if available in a compact form
|
|
159
|
+
if node.actual_total_time is not None:
|
|
160
|
+
output += (
|
|
161
|
+
f" [Actual: {node.actual_startup_time:.2f}..{node.actual_total_time:.2f} ms, Rows: {node.actual_rows}, Loops: {node.actual_loops}]"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Add filter if present
|
|
165
|
+
if node.filter:
|
|
166
|
+
filter_text = node.filter
|
|
167
|
+
# Truncate long filters for readability
|
|
168
|
+
if len(filter_text) > 100:
|
|
169
|
+
filter_text = filter_text[:97] + "..."
|
|
170
|
+
output += f"\n{indent} Filter: {filter_text}"
|
|
171
|
+
|
|
172
|
+
# Add buffer information if available in a compact form
|
|
173
|
+
if node.shared_hit_blocks is not None:
|
|
174
|
+
output += f"\n{indent} Buffers - hit: {node.shared_hit_blocks}, read: {node.shared_read_blocks}, written: {node.shared_written_blocks}"
|
|
175
|
+
|
|
176
|
+
# Recursively format children
|
|
177
|
+
if node.children:
|
|
178
|
+
for child in node.children:
|
|
179
|
+
output += "\n" + ExplainPlanArtifact._format_plan_node(child, level + 1)
|
|
180
|
+
|
|
181
|
+
return output
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def from_json_data(cls, plan_data: dict[str, Any]) -> "ExplainPlanArtifact":
|
|
185
|
+
if "Plan" not in plan_data:
|
|
186
|
+
raise ValueError("Missing 'Plan' field in explain plan data")
|
|
187
|
+
|
|
188
|
+
# Create plan tree from the "Plan" field
|
|
189
|
+
plan_tree = PlanNode.from_json_data(plan_data["Plan"])
|
|
190
|
+
|
|
191
|
+
# Extract optional timing information
|
|
192
|
+
planning_time = plan_data.get("Planning Time")
|
|
193
|
+
execution_time = plan_data.get("Execution Time")
|
|
194
|
+
|
|
195
|
+
return cls(
|
|
196
|
+
value=json.dumps(plan_data, indent=2),
|
|
197
|
+
plan_tree=plan_tree,
|
|
198
|
+
planning_time=planning_time,
|
|
199
|
+
execution_time=execution_time,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def format_plan_summary(plan_data):
|
|
204
|
+
"""Extract and format key information from a raw plan data."""
|
|
205
|
+
if not plan_data:
|
|
206
|
+
return "No plan data available"
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
# Create a PlanNode from the raw JSON data
|
|
210
|
+
if "Plan" in plan_data:
|
|
211
|
+
plan_node = PlanNode.from_json_data(plan_data["Plan"])
|
|
212
|
+
|
|
213
|
+
# Use _format_plan_node to format the output
|
|
214
|
+
plan_tree = ExplainPlanArtifact._format_plan_node(plan_node, 0)
|
|
215
|
+
|
|
216
|
+
return f"{plan_tree}"
|
|
217
|
+
else:
|
|
218
|
+
return "Invalid plan data (missing Plan field)"
|
|
219
|
+
|
|
220
|
+
except Exception as e:
|
|
221
|
+
return f"Error summarizing plan: {e}"
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def create_plan_diff(before_plan: dict[str, Any], after_plan: dict[str, Any]) -> str:
|
|
225
|
+
"""Generate a textual diff between two explain plans.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
before_plan: The explain plan before changes
|
|
229
|
+
after_plan: The explain plan after changes
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
A string containing a readable diff between the two plans
|
|
233
|
+
"""
|
|
234
|
+
if not before_plan or not after_plan:
|
|
235
|
+
return "Cannot generate diff: Missing plan data"
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
# Create PlanNode objects from the plans
|
|
239
|
+
before_tree = PlanNode.from_json_data(before_plan["Plan"]) if "Plan" in before_plan else None
|
|
240
|
+
after_tree = PlanNode.from_json_data(after_plan["Plan"]) if "Plan" in after_plan else None
|
|
241
|
+
|
|
242
|
+
if not before_tree or not after_tree:
|
|
243
|
+
return "Cannot generate diff: Invalid plan structure"
|
|
244
|
+
|
|
245
|
+
# Format the plans as text
|
|
246
|
+
before_lines = ExplainPlanArtifact._format_plan_node(before_tree).split("\n")
|
|
247
|
+
after_lines = ExplainPlanArtifact._format_plan_node(after_tree).split("\n")
|
|
248
|
+
|
|
249
|
+
# Generate a readable diff with context
|
|
250
|
+
diff_lines = []
|
|
251
|
+
diff_lines.append("PLAN CHANGES:")
|
|
252
|
+
diff_lines.append("------------")
|
|
253
|
+
|
|
254
|
+
# Extract cost information for a summary
|
|
255
|
+
before_cost = before_tree.total_cost
|
|
256
|
+
after_cost = after_tree.total_cost
|
|
257
|
+
improvement = calculate_improvement_multiple(before_cost, after_cost)
|
|
258
|
+
|
|
259
|
+
diff_lines.append(f"Cost: {before_cost:.2f} → {after_cost:.2f} ({improvement:.1f}x improvement)")
|
|
260
|
+
diff_lines.append("")
|
|
261
|
+
|
|
262
|
+
# Node type changes - a simplified structural diff
|
|
263
|
+
diff_lines.append("Operation Changes:")
|
|
264
|
+
|
|
265
|
+
# Helper function to extract node types with indentation
|
|
266
|
+
def extract_node_types(node, level=0, result=None):
|
|
267
|
+
if result is None:
|
|
268
|
+
result = []
|
|
269
|
+
indent = " " * level
|
|
270
|
+
node_info = f"{indent}→ {node.node_type}"
|
|
271
|
+
if node.relation_name:
|
|
272
|
+
node_info += f" on {node.relation_name}"
|
|
273
|
+
result.append(node_info)
|
|
274
|
+
for child in node.children:
|
|
275
|
+
extract_node_types(child, level + 1, result)
|
|
276
|
+
return result
|
|
277
|
+
|
|
278
|
+
before_structure = extract_node_types(before_tree)
|
|
279
|
+
after_structure = extract_node_types(after_tree)
|
|
280
|
+
|
|
281
|
+
# Generate the structural diff
|
|
282
|
+
structure_diff = list(
|
|
283
|
+
difflib.unified_diff(
|
|
284
|
+
before_structure,
|
|
285
|
+
after_structure,
|
|
286
|
+
n=1, # Context lines
|
|
287
|
+
lineterm="",
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Add structural diff to output
|
|
292
|
+
if structure_diff:
|
|
293
|
+
diff_lines.extend(structure_diff)
|
|
294
|
+
else:
|
|
295
|
+
diff_lines.append("No structural changes detected")
|
|
296
|
+
|
|
297
|
+
# Add more specific details about key changes
|
|
298
|
+
diff_lines.append("")
|
|
299
|
+
diff_lines.append("Major Changes:")
|
|
300
|
+
|
|
301
|
+
# Look for significant changes like seq scan to index scan, changed filters, etc.
|
|
302
|
+
# This requires traversing both trees and comparing nodes
|
|
303
|
+
|
|
304
|
+
# For simplicity, we'll just list key changes in cost and rows
|
|
305
|
+
if before_tree.node_type != after_tree.node_type:
|
|
306
|
+
diff_lines.append(f"- Root operation changed: {before_tree.node_type} → {after_tree.node_type}")
|
|
307
|
+
|
|
308
|
+
# Compare scan methods used
|
|
309
|
+
before_scans = [line for line in before_lines if "Seq Scan" in line]
|
|
310
|
+
after_scans = [line for line in after_lines if "Seq Scan" in line]
|
|
311
|
+
if len(before_scans) > len(after_scans):
|
|
312
|
+
diff_lines.append(f"- {len(before_scans) - len(after_scans)} sequential scans replaced with more efficient access methods")
|
|
313
|
+
|
|
314
|
+
# Look for new index scans
|
|
315
|
+
before_idx_scans = [line for line in before_lines if "Index Scan" in line]
|
|
316
|
+
after_idx_scans = [line for line in after_lines if "Index Scan" in line]
|
|
317
|
+
if len(after_idx_scans) > len(before_idx_scans):
|
|
318
|
+
diff_lines.append(f"- {len(after_idx_scans) - len(before_idx_scans)} new index scans used")
|
|
319
|
+
|
|
320
|
+
return "\n".join(diff_lines)
|
|
321
|
+
|
|
322
|
+
except Exception as e:
|
|
323
|
+
return f"Error generating plan diff: {e}"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from ..sql import SqlDriver
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BufferHealthCalc:
|
|
7
|
+
_cached_indexes: list[dict[str, Any]] | None = None
|
|
8
|
+
|
|
9
|
+
def __init__(self, sql_driver: SqlDriver):
|
|
10
|
+
self.sql_driver = sql_driver
|
|
11
|
+
|
|
12
|
+
async def index_hit_rate(self, threshold: float = 0.95) -> str:
|
|
13
|
+
"""Calculate the index cache hit rate.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
String describing the index cache hit rate as a percentage and comparison to threshold
|
|
17
|
+
"""
|
|
18
|
+
result = await self.sql_driver.execute_query("""
|
|
19
|
+
SELECT
|
|
20
|
+
(sum(idx_blks_hit)) / nullif(sum(idx_blks_hit + idx_blks_read), 0) AS rate
|
|
21
|
+
FROM
|
|
22
|
+
pg_statio_user_indexes
|
|
23
|
+
""")
|
|
24
|
+
|
|
25
|
+
result_list = [dict(x.cells) for x in result] if result else []
|
|
26
|
+
|
|
27
|
+
if not result_list or result_list[0]["rate"] is None:
|
|
28
|
+
return "No index cache statistics available."
|
|
29
|
+
|
|
30
|
+
hit_rate = float(result_list[0]["rate"]) * 100
|
|
31
|
+
threshold_pct = threshold * 100
|
|
32
|
+
|
|
33
|
+
if hit_rate >= threshold_pct:
|
|
34
|
+
return f"Index cache hit rate: {hit_rate:.1f}% (above {threshold_pct:.1f}% threshold)"
|
|
35
|
+
else:
|
|
36
|
+
return f"Index cache hit rate: {hit_rate:.1f}% (below {threshold_pct:.1f}% threshold)"
|
|
37
|
+
|
|
38
|
+
async def table_hit_rate(self, threshold: float = 0.95) -> str:
|
|
39
|
+
"""Calculate the table cache hit rate.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
String describing the table cache hit rate as a percentage and comparison to threshold
|
|
43
|
+
"""
|
|
44
|
+
result = await self.sql_driver.execute_query("""
|
|
45
|
+
SELECT
|
|
46
|
+
sum(heap_blks_hit) / nullif(sum(heap_blks_hit + heap_blks_read), 0) AS rate
|
|
47
|
+
FROM
|
|
48
|
+
pg_statio_user_tables
|
|
49
|
+
""")
|
|
50
|
+
|
|
51
|
+
result_list = [dict(x.cells) for x in result] if result else []
|
|
52
|
+
|
|
53
|
+
if not result_list or result_list[0]["rate"] is None:
|
|
54
|
+
return "No table cache statistics available."
|
|
55
|
+
|
|
56
|
+
hit_rate = float(result_list[0]["rate"]) * 100
|
|
57
|
+
threshold_pct = threshold * 100
|
|
58
|
+
|
|
59
|
+
if hit_rate >= threshold_pct:
|
|
60
|
+
return f"Table cache hit rate: {hit_rate:.1f}% (above {threshold_pct:.1f}% threshold)"
|
|
61
|
+
else:
|
|
62
|
+
return f"Table cache hit rate: {hit_rate:.1f}% (below {threshold_pct:.1f}% threshold)"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from ..sql import SqlDriver
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ConnectionHealthMetrics:
|
|
8
|
+
total_connections: int
|
|
9
|
+
idle_connections: int
|
|
10
|
+
max_total_connections: int
|
|
11
|
+
max_idle_connections: int
|
|
12
|
+
is_total_connections_healthy: bool
|
|
13
|
+
is_idle_connections_healthy: bool
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def is_healthy(self) -> bool:
|
|
17
|
+
return self.is_total_connections_healthy and self.is_idle_connections_healthy
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConnectionHealthCalc:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
sql_driver: SqlDriver,
|
|
24
|
+
max_total_connections: int = 500,
|
|
25
|
+
max_idle_connections: int = 100,
|
|
26
|
+
):
|
|
27
|
+
self.sql_driver = sql_driver
|
|
28
|
+
self.max_total_connections = max_total_connections
|
|
29
|
+
self.max_idle_connections = max_idle_connections
|
|
30
|
+
|
|
31
|
+
async def total_connections_check(self) -> str:
|
|
32
|
+
"""Check if total number of connections is within healthy limits."""
|
|
33
|
+
total = await self._get_total_connections()
|
|
34
|
+
|
|
35
|
+
if total <= self.max_total_connections:
|
|
36
|
+
return f"Total connections healthy: {total}"
|
|
37
|
+
return f"High number of connections: {total} (max: {self.max_total_connections})"
|
|
38
|
+
|
|
39
|
+
async def idle_connections_check(self) -> str:
|
|
40
|
+
"""Check if number of idle connections is within healthy limits."""
|
|
41
|
+
idle = await self._get_idle_connections()
|
|
42
|
+
|
|
43
|
+
if idle <= self.max_idle_connections:
|
|
44
|
+
return f"Idle connections healthy: {idle}"
|
|
45
|
+
return f"High number of idle connections: {idle} (max: {self.max_idle_connections})"
|
|
46
|
+
|
|
47
|
+
async def connection_health_check(self) -> str:
|
|
48
|
+
"""Run all connection health checks and return combined results."""
|
|
49
|
+
total = await self._get_total_connections()
|
|
50
|
+
idle = await self._get_idle_connections()
|
|
51
|
+
|
|
52
|
+
if total > self.max_total_connections:
|
|
53
|
+
return f"High number of connections: {total}"
|
|
54
|
+
elif idle > self.max_idle_connections:
|
|
55
|
+
return f"High number of connections idle in transaction: {idle}"
|
|
56
|
+
else:
|
|
57
|
+
return f"Connections healthy: {total} total, {idle} idle"
|
|
58
|
+
|
|
59
|
+
async def _get_total_connections(self) -> int:
|
|
60
|
+
"""Get the total number of database connections."""
|
|
61
|
+
result = await self.sql_driver.execute_query("""
|
|
62
|
+
SELECT COUNT(*) as count
|
|
63
|
+
FROM pg_stat_activity
|
|
64
|
+
""")
|
|
65
|
+
result_list = [dict(x.cells) for x in result] if result else []
|
|
66
|
+
return result_list[0]["count"] if result_list else 0
|
|
67
|
+
|
|
68
|
+
async def _get_idle_connections(self) -> int:
|
|
69
|
+
"""Get the number of connections that are idle in transaction."""
|
|
70
|
+
result = await self.sql_driver.execute_query("""
|
|
71
|
+
SELECT COUNT(*) as count
|
|
72
|
+
FROM pg_stat_activity
|
|
73
|
+
WHERE state = 'idle in transaction'
|
|
74
|
+
""")
|
|
75
|
+
result_list = [dict(x.cells) for x in result] if result else []
|
|
76
|
+
return result_list[0]["count"] if result_list else 0
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from ..sql import SqlDriver
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ConstraintMetrics:
|
|
8
|
+
schema: str
|
|
9
|
+
table: str
|
|
10
|
+
name: str
|
|
11
|
+
referenced_schema: str | None
|
|
12
|
+
referenced_table: str | None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConstraintHealthCalc:
|
|
16
|
+
def __init__(self, sql_driver: SqlDriver):
|
|
17
|
+
self.sql_driver = sql_driver
|
|
18
|
+
|
|
19
|
+
async def invalid_constraints_check(self) -> str:
|
|
20
|
+
"""Check for any invalid constraints in the database.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
String describing any invalid constraints found
|
|
24
|
+
"""
|
|
25
|
+
metrics = await self._get_invalid_constraints()
|
|
26
|
+
|
|
27
|
+
if not metrics:
|
|
28
|
+
return "No invalid constraints found."
|
|
29
|
+
|
|
30
|
+
result = ["Invalid constraints found:"]
|
|
31
|
+
for metric in metrics:
|
|
32
|
+
if metric.referenced_table:
|
|
33
|
+
result.append(
|
|
34
|
+
f"Constraint '{metric.name}' on table '{metric.schema}.{metric.table}' "
|
|
35
|
+
f"referencing '{metric.referenced_schema}.{metric.referenced_table}' is invalid"
|
|
36
|
+
)
|
|
37
|
+
else:
|
|
38
|
+
result.append(f"Constraint '{metric.name}' on table '{metric.schema}.{metric.table}' is invalid")
|
|
39
|
+
return "\n".join(result)
|
|
40
|
+
|
|
41
|
+
async def _get_invalid_constraints(self) -> list[ConstraintMetrics]:
|
|
42
|
+
"""Get all invalid constraints in the database."""
|
|
43
|
+
results = await self.sql_driver.execute_query("""
|
|
44
|
+
SELECT
|
|
45
|
+
nsp.nspname AS schema,
|
|
46
|
+
rel.relname AS table,
|
|
47
|
+
con.conname AS name,
|
|
48
|
+
fnsp.nspname AS referenced_schema,
|
|
49
|
+
frel.relname AS referenced_table
|
|
50
|
+
FROM
|
|
51
|
+
pg_catalog.pg_constraint con
|
|
52
|
+
INNER JOIN
|
|
53
|
+
pg_catalog.pg_class rel ON rel.oid = con.conrelid
|
|
54
|
+
LEFT JOIN
|
|
55
|
+
pg_catalog.pg_class frel ON frel.oid = con.confrelid
|
|
56
|
+
LEFT JOIN
|
|
57
|
+
pg_catalog.pg_namespace nsp ON nsp.oid = con.connamespace
|
|
58
|
+
LEFT JOIN
|
|
59
|
+
pg_catalog.pg_namespace fnsp ON fnsp.oid = frel.relnamespace
|
|
60
|
+
WHERE
|
|
61
|
+
con.convalidated = 'f'
|
|
62
|
+
""")
|
|
63
|
+
|
|
64
|
+
if not results:
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
result_list = [dict(x.cells) for x in results]
|
|
68
|
+
|
|
69
|
+
return [
|
|
70
|
+
ConstraintMetrics(
|
|
71
|
+
schema=row["schema"],
|
|
72
|
+
table=row["table"],
|
|
73
|
+
name=row["name"],
|
|
74
|
+
referenced_schema=row["referenced_schema"],
|
|
75
|
+
referenced_table=row["referenced_table"],
|
|
76
|
+
)
|
|
77
|
+
for row in result_list
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
async def _get_total_constraints(self) -> int:
|
|
81
|
+
"""Get the total number of constraints."""
|
|
82
|
+
result = await self.sql_driver.execute_query("""
|
|
83
|
+
SELECT COUNT(*) as count
|
|
84
|
+
FROM information_schema.table_constraints
|
|
85
|
+
""")
|
|
86
|
+
if not result:
|
|
87
|
+
return 0
|
|
88
|
+
result_list = [dict(x.cells) for x in result]
|
|
89
|
+
return result_list[0]["count"] if result_list else 0
|
|
90
|
+
|
|
91
|
+
async def _get_active_constraints(self) -> int:
|
|
92
|
+
"""Get the number of active constraints."""
|
|
93
|
+
result = await self.sql_driver.execute_query("""
|
|
94
|
+
SELECT COUNT(*) as count
|
|
95
|
+
FROM information_schema.table_constraints
|
|
96
|
+
WHERE is_deferrable = 'NO'
|
|
97
|
+
""")
|
|
98
|
+
if not result:
|
|
99
|
+
return 0
|
|
100
|
+
result_list = [dict(x.cells) for x in result]
|
|
101
|
+
return result_list[0]["count"] if result_list else 0
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
import mcp.types as types
|
|
8
|
+
|
|
9
|
+
from .buffer_health_calc import BufferHealthCalc
|
|
10
|
+
from .connection_health_calc import ConnectionHealthCalc
|
|
11
|
+
from .constraint_health_calc import ConstraintHealthCalc
|
|
12
|
+
from .index_health_calc import IndexHealthCalc
|
|
13
|
+
from .replication_calc import ReplicationCalc
|
|
14
|
+
from .sequence_health_calc import SequenceHealthCalc
|
|
15
|
+
from .vacuum_health_calc import VacuumHealthCalc
|
|
16
|
+
|
|
17
|
+
ResponseType = List[types.TextContent | types.ImageContent | types.EmbeddedResource]
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HealthType(str, Enum):
|
|
23
|
+
INDEX = "index"
|
|
24
|
+
CONNECTION = "connection"
|
|
25
|
+
VACUUM = "vacuum"
|
|
26
|
+
SEQUENCE = "sequence"
|
|
27
|
+
REPLICATION = "replication"
|
|
28
|
+
BUFFER = "buffer"
|
|
29
|
+
CONSTRAINT = "constraint"
|
|
30
|
+
ALL = "all"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DatabaseHealthTool:
|
|
34
|
+
"""Tool for analyzing database health metrics."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, sql_driver):
|
|
37
|
+
self.sql_driver = sql_driver
|
|
38
|
+
|
|
39
|
+
async def health(self, health_type: str) -> str:
|
|
40
|
+
"""Run database health checks for the specified components.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
health_type: Comma-separated list of health check types to perform
|
|
44
|
+
Valid values: index, connection, vacuum, sequence, replication, buffer, constraint, all
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
A string with the health check results
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
result = ""
|
|
51
|
+
try:
|
|
52
|
+
health_types = {HealthType(x.strip()) for x in health_type.split(",")}
|
|
53
|
+
except ValueError:
|
|
54
|
+
return (
|
|
55
|
+
f"Invalid health types provided: '{health_type}'. "
|
|
56
|
+
+ f"Valid values are: {', '.join(sorted([t.value for t in HealthType]))}. "
|
|
57
|
+
+ "Please try again with a comma-separated list of valid health types."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if HealthType.ALL in health_types:
|
|
61
|
+
health_types = [t.value for t in HealthType if t != HealthType.ALL]
|
|
62
|
+
|
|
63
|
+
if HealthType.INDEX in health_types:
|
|
64
|
+
index_health = IndexHealthCalc(self.sql_driver)
|
|
65
|
+
result += "Invalid index check: " + await index_health.invalid_index_check() + "\n"
|
|
66
|
+
result += "Duplicate index check: " + await index_health.duplicate_index_check() + "\n"
|
|
67
|
+
result += "Index bloat: " + await index_health.index_bloat() + "\n"
|
|
68
|
+
result += "Unused index check: " + await index_health.unused_indexes() + "\n"
|
|
69
|
+
|
|
70
|
+
if HealthType.CONNECTION in health_types:
|
|
71
|
+
connection_health = ConnectionHealthCalc(self.sql_driver)
|
|
72
|
+
result += "Connection health: " + await connection_health.connection_health_check() + "\n"
|
|
73
|
+
|
|
74
|
+
if HealthType.VACUUM in health_types:
|
|
75
|
+
vacuum_health = VacuumHealthCalc(self.sql_driver)
|
|
76
|
+
result += "Vacuum health: " + await vacuum_health.transaction_id_danger_check() + "\n"
|
|
77
|
+
|
|
78
|
+
if HealthType.SEQUENCE in health_types:
|
|
79
|
+
sequence_health = SequenceHealthCalc(self.sql_driver)
|
|
80
|
+
result += "Sequence health: " + await sequence_health.sequence_danger_check() + "\n"
|
|
81
|
+
|
|
82
|
+
if HealthType.REPLICATION in health_types:
|
|
83
|
+
replication_health = ReplicationCalc(self.sql_driver)
|
|
84
|
+
result += "Replication health: " + await replication_health.replication_health_check() + "\n"
|
|
85
|
+
|
|
86
|
+
if HealthType.BUFFER in health_types:
|
|
87
|
+
buffer_health = BufferHealthCalc(self.sql_driver)
|
|
88
|
+
result += "Buffer health for indexes: " + await buffer_health.index_hit_rate() + "\n"
|
|
89
|
+
result += "Buffer health for tables: " + await buffer_health.table_hit_rate() + "\n"
|
|
90
|
+
|
|
91
|
+
if HealthType.CONSTRAINT in health_types:
|
|
92
|
+
constraint_health = ConstraintHealthCalc(self.sql_driver)
|
|
93
|
+
result += "Constraint health: " + await constraint_health.invalid_constraints_check() + "\n"
|
|
94
|
+
|
|
95
|
+
return result if result else "No health checks were performed."
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.error(f"Error calculating database health: {e}", exc_info=True)
|
|
98
|
+
return f"Error calculating database health: {e}"
|