junaid0600 commited on
Commit
ff10d5b
Β·
1 Parent(s): 8cb206e

Add db_simulator and scenario files

Browse files
Files changed (1) hide show
  1. env/db_simulator.py +292 -0
env/db_simulator.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ env/db_simulator.py β€” SQL Database Engineer Agent
3
+ Simulates a production database responding to optimization actions.
4
+ Core mechanism: index coverage reduces query execution time by up to 85-90%.
5
+ """
6
+
7
+ import math
8
+ import random
9
+ from typing import Optional
10
+
11
+
12
+ class DatabaseSimulator:
13
+ """
14
+ Simulates a production database that degrades over time.
15
+ The agent applies optimization actions and sees performance scores change.
16
+
17
+ Performance score: 0-100 (100 = all queries running at target speed).
18
+ The agent's goal: get performance_score >= target_score.
19
+ """
20
+
21
+ def __init__(self, scenario: dict):
22
+ self.scenario = scenario
23
+ self.tables = {t["name"]: dict(t) for t in scenario["tables"]}
24
+ self.queries = [dict(q) for q in scenario["slow_queries"]]
25
+ self.indexes = {
26
+ name: list(t.get("indexes", ["PRIMARY"]))
27
+ for name, t in self.tables.items()
28
+ }
29
+ self.stats_fresh = {name: False for name in self.tables}
30
+ self.partitioned = {name: False for name in self.tables}
31
+ self.baseline = self._compute_score()
32
+ self.history = [self.baseline]
33
+ self.best_score = self.baseline
34
+ self.target_score = scenario.get("target_score", 85.0)
35
+
36
+ # ─────────────────────────────────────────────
37
+ # PUBLIC ACTIONS
38
+ # ─────────────────────────────────────────────
39
+
40
+ def apply_action(self, action_type: str, payload: dict) -> dict:
41
+ """
42
+ Apply an optimization action to the database.
43
+ Returns delta showing performance change.
44
+ """
45
+ old_score = self._compute_score()
46
+ affected = []
47
+
48
+ if action_type == "create_index":
49
+ table = payload.get("table", "")
50
+ cols = payload.get("columns", [])
51
+ if isinstance(cols, str):
52
+ cols = [c.strip() for c in cols.split(",")]
53
+ idx_name = "idx_" + "_".join(cols)
54
+ if table in self.indexes and idx_name not in self.indexes[table]:
55
+ self.indexes[table].append(idx_name)
56
+ affected = self._queries_benefiting_from_index(table, cols)
57
+ else:
58
+ # Duplicate index β€” no benefit
59
+ return {
60
+ "old_score": old_score, "new_score": old_score,
61
+ "delta": 0.0, "affected_queries": [],
62
+ "improved": False, "message": "Index already exists or table not found."
63
+ }
64
+
65
+ elif action_type == "rewrite_query":
66
+ qid = payload.get("query_id", "")
67
+ new_sql = payload.get("new_sql", "")
68
+ for q in self.queries:
69
+ if q["id"] == qid:
70
+ improvement = self._estimate_rewrite(new_sql, q)
71
+ q["avg_ms"] = max(10, int(q["avg_ms"] * (1 - improvement)))
72
+ affected = [qid]
73
+ break
74
+
75
+ elif action_type == "partition_table":
76
+ table = payload.get("table", "")
77
+ if table in self.tables and not self.partitioned.get(table):
78
+ self.partitioned[table] = True
79
+ affected = [q["id"] for q in self.queries if table in q.get("sql", "")]
80
+
81
+ elif action_type == "analyze_statistics":
82
+ table = payload.get("table", "")
83
+ if table in self.tables:
84
+ self.stats_fresh[table] = True
85
+ affected = [q["id"] for q in self.queries if table in q.get("sql", "")]
86
+
87
+ elif action_type == "drop_index":
88
+ table = payload.get("table", "")
89
+ idx_name = payload.get("index_name", "")
90
+ if idx_name in self.indexes.get(table, []) and idx_name != "PRIMARY":
91
+ self.indexes[table].remove(idx_name)
92
+
93
+ elif action_type == "add_column":
94
+ table = payload.get("table", "")
95
+ col = payload.get("column", "")
96
+ purpose = payload.get("purpose", "")
97
+ if table in self.tables:
98
+ if "extra_columns" not in self.tables[table]:
99
+ self.tables[table]["extra_columns"] = []
100
+ self.tables[table]["extra_columns"].append(col)
101
+ # Denormalization can help JOINy queries
102
+ affected = [
103
+ q["id"] for q in self.queries
104
+ if "join" in q.get("sql", "").lower() and table in q.get("sql", "")
105
+ ]
106
+
107
+ new_score = self._compute_score()
108
+ self.history.append(new_score)
109
+ if new_score > self.best_score:
110
+ self.best_score = new_score
111
+
112
+ return {
113
+ "old_score": round(old_score, 2),
114
+ "new_score": round(new_score, 2),
115
+ "delta": round(new_score - old_score, 2),
116
+ "affected_queries": affected,
117
+ "improved": new_score > old_score,
118
+ }
119
+
120
+ def inspect_query(self, query_id: str) -> dict:
121
+ """
122
+ EXPLAIN a slow query β€” reveals scan type, rows examined, cost.
123
+ This is the agent's primary investigation tool.
124
+ """
125
+ for q in self.queries:
126
+ if q["id"] == query_id:
127
+ has_index = self._check_query_index_coverage(q) > 0.1
128
+ is_partition = self.partitioned.get(q.get("main_table", ""), False)
129
+ rows_examined = 50 if has_index else q.get("rows_examined",
130
+ self.tables.get(q.get("main_table", ""), {}).get("rows", 50000))
131
+
132
+ return {
133
+ "query_id": query_id,
134
+ "sql": q["sql"],
135
+ "avg_ms": q["avg_ms"],
136
+ "scan_type": "INDEX RANGE SCAN" if has_index else "FULL TABLE SCAN",
137
+ "rows_examined": rows_examined,
138
+ "partitioned": is_partition,
139
+ "optimization_hint": (
140
+ "Query is using index efficiently."
141
+ if has_index
142
+ else "No index covering WHERE columns. Consider adding composite index."
143
+ ),
144
+ "main_table": q.get("main_table", "unknown"),
145
+ }
146
+ return {"error": f"Query '{query_id}' not found"}
147
+
148
+ def analyze_indexes(self, table: str) -> dict:
149
+ """
150
+ Show all indexes on a table + usage stats + missing index hints.
151
+ """
152
+ if table not in self.tables:
153
+ return {"error": f"Table '{table}' not found"}
154
+
155
+ existing = self.indexes.get(table, [])
156
+ hints = [
157
+ h for h in self.scenario.get("missing_index_hints", [])
158
+ if h.get("table") == table
159
+ ]
160
+ used_by = []
161
+ for q in self.queries:
162
+ cov = self._check_query_index_coverage(q)
163
+ if table in q.get("sql", "") and cov > 0.1:
164
+ used_by.append(q["id"])
165
+
166
+ return {
167
+ "table": table,
168
+ "row_count": self.tables[table].get("rows", 0),
169
+ "existing_indexes": existing,
170
+ "indexes_used_by": used_by,
171
+ "missing_hints": hints,
172
+ "stats_fresh": self.stats_fresh.get(table, False),
173
+ "partitioned": self.partitioned.get(table, False),
174
+ "size_mb": self.tables[table].get("size_mb", 0),
175
+ }
176
+
177
+ # ─────────────────────────────────────────────
178
+ # STATE
179
+ # ─────────────────────────────────────────────
180
+
181
+ def get_current_state(self) -> dict:
182
+ """Returns the full current DB state for the Observation."""
183
+ return {
184
+ "performance_score": round(self._compute_score(), 2),
185
+ "baseline_score": round(self.baseline, 2),
186
+ "target_score": self.target_score,
187
+ "tables": list(self.tables.values()),
188
+ "slow_queries": self.queries,
189
+ "indexes": self.indexes,
190
+ "history": self.history,
191
+ "best_score": round(self.best_score, 2),
192
+ }
193
+
194
+ def get_performance_score(self) -> float:
195
+ return round(self._compute_score(), 2)
196
+
197
+ def is_target_reached(self) -> bool:
198
+ return self._compute_score() >= self.target_score
199
+
200
+ # ─────────────────────────────────────────────
201
+ # INTERNAL SCORING ENGINE
202
+ # ─────────────────────────────────────────────
203
+
204
+ def _compute_score(self) -> float:
205
+ """
206
+ Core scoring: calculates performance score 0-100.
207
+ Higher = better. Based on how fast queries run given current indexes.
208
+ """
209
+ if not self.queries:
210
+ return 0.0
211
+
212
+ scores = []
213
+ for q in self.queries:
214
+ table = q.get("main_table", "")
215
+ coverage = self._check_query_index_coverage(q)
216
+ part_bonus = 0.30 if self.partitioned.get(table, False) else 0.0
217
+ stats_bonus = 0.05 if self.stats_fresh.get(table, False) else 0.0
218
+ total_reduction = min(coverage * 0.85 + part_bonus + stats_bonus, 0.97)
219
+ effective_ms = q["avg_ms"] * (1 - total_reduction)
220
+ # Score formula: 100ms = score 99, 1000ms = score 90, 8500ms = ~14
221
+ score = max(0.0, 100.0 - (effective_ms / 100.0))
222
+ scores.append(score)
223
+
224
+ return round(sum(scores) / len(scores), 2)
225
+
226
+ def _check_query_index_coverage(self, query: dict) -> float:
227
+ """
228
+ Returns 0.0-1.0 representing how well indexes cover this query's WHERE clause.
229
+ 0.0 = full table scan, 1.0 = perfect index coverage.
230
+ """
231
+ sql = query.get("sql", "").lower()
232
+ for table, indexes in self.indexes.items():
233
+ if table not in sql:
234
+ continue
235
+ for idx in indexes:
236
+ if idx == "PRIMARY":
237
+ # Primary key only helps if query filters by primary key
238
+ if "where id=" in sql or "where id =" in sql:
239
+ return 0.95
240
+ continue
241
+ # Extract columns from index name (idx_col1_col2)
242
+ cols = idx.replace("idx_", "").split("_")
243
+ matches = sum(1 for c in cols if c in sql)
244
+ if matches >= 2:
245
+ return 0.90 # Composite index β€” excellent coverage
246
+ if matches == 1:
247
+ return 0.60 # Single column β€” partial coverage
248
+ return 0.0
249
+
250
+ def _queries_benefiting_from_index(self, table: str, cols: list) -> list:
251
+ """Returns query IDs that would benefit from an index on given table/columns."""
252
+ benefiting = []
253
+ for q in self.queries:
254
+ sql = q.get("sql", "").lower()
255
+ if table in sql and any(c.lower() in sql for c in cols):
256
+ benefiting.append(q["id"])
257
+ return benefiting
258
+
259
+ def _estimate_rewrite(self, new_sql: str, query: dict) -> float:
260
+ """
261
+ Estimates improvement factor from a query rewrite (0.0 to 0.70).
262
+ Checks for common optimization patterns.
263
+ """
264
+ new_lower = new_sql.lower()
265
+ old_lower = query.get("sql", "").lower()
266
+ improvement = 0.0
267
+
268
+ # Remove SELECT * β†’ specific columns
269
+ if "select *" not in new_lower and "select *" in old_lower:
270
+ improvement += 0.20
271
+
272
+ # Add LIMIT clause
273
+ if "limit " in new_lower and "limit " not in old_lower:
274
+ improvement += 0.15
275
+
276
+ # Use EXISTS instead of IN subquery
277
+ if "exists" in new_lower and "in (select" in old_lower:
278
+ improvement += 0.25
279
+
280
+ # Use INNER JOIN instead of implicit cross join
281
+ if "inner join" in new_lower and "," in old_lower and "join" not in old_lower:
282
+ improvement += 0.30
283
+
284
+ # Add WHERE clause that was missing
285
+ if "where" in new_lower and "where" not in old_lower:
286
+ improvement += 0.35
287
+
288
+ # Use COALESCE / ISNULL
289
+ if "coalesce" in new_lower:
290
+ improvement += 0.05
291
+
292
+ return min(improvement, 0.70)