mdbkit 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.
- mdbkit/__init__.py +3 -0
- mdbkit/advisor.py +302 -0
- mdbkit/analysis.py +436 -0
- mdbkit/cli.py +256 -0
- mdbkit/explain.py +244 -0
- mdbkit/filtering.py +55 -0
- mdbkit/parser.py +149 -0
- mdbkit/render.py +141 -0
- mdbkit/scripts.py +69 -0
- mdbkit/triage.py +360 -0
- mdbkit-0.1.0.dist-info/METADATA +207 -0
- mdbkit-0.1.0.dist-info/RECORD +16 -0
- mdbkit-0.1.0.dist-info/WHEEL +5 -0
- mdbkit-0.1.0.dist-info/entry_points.txt +2 -0
- mdbkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- mdbkit-0.1.0.dist-info/top_level.txt +1 -0
mdbkit/__init__.py
ADDED
mdbkit/advisor.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Deterministic index advisor.
|
|
2
|
+
|
|
3
|
+
Rules-based, no AI, no network. Given aggregated slow-query shapes (and
|
|
4
|
+
optionally the deployment's existing indexes and a sampled schema), it
|
|
5
|
+
proposes *candidate* indexes using the ESR guideline:
|
|
6
|
+
|
|
7
|
+
Equality fields first, then Sort fields, then Range fields.
|
|
8
|
+
|
|
9
|
+
Design principles (deliberate, not accidental):
|
|
10
|
+
* Recommendations are candidates to validate, never commands to run blindly.
|
|
11
|
+
* Every recommendation carries evidence, confidence, tradeoffs, and a
|
|
12
|
+
validation step.
|
|
13
|
+
* We never advise dropping an index; at most we flag overlap to investigate.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Dict, List, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
from .analysis import (
|
|
23
|
+
EQUALITY_OPS,
|
|
24
|
+
LOW_SELECTIVITY_OPS,
|
|
25
|
+
RANGE_OPS,
|
|
26
|
+
ShapeStats,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
Confidence = str # "high" | "medium" | "low"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Recommendation:
|
|
34
|
+
ns: str
|
|
35
|
+
shape: str
|
|
36
|
+
candidate: List[Tuple[str, int]] # ordered (field, direction)
|
|
37
|
+
confidence: Confidence
|
|
38
|
+
evidence: List[str]
|
|
39
|
+
caveats: List[str]
|
|
40
|
+
covered_by: Optional[str] = None
|
|
41
|
+
extends: Optional[str] = None
|
|
42
|
+
validation: str = (
|
|
43
|
+
"Re-run the query with .explain('executionStats') after creating the "
|
|
44
|
+
"index on a staging copy: keysExamined should be close to nReturned "
|
|
45
|
+
"and the plan should no longer contain COLLSCAN or an in-memory SORT."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def candidate_str(self) -> str:
|
|
49
|
+
return "{ " + ", ".join(f"{f}: {d}" for f, d in self.candidate) + " }"
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict:
|
|
52
|
+
return {
|
|
53
|
+
"ns": self.ns,
|
|
54
|
+
"shape": self.shape,
|
|
55
|
+
"candidateIndex": {f: d for f, d in self.candidate},
|
|
56
|
+
"confidence": self.confidence,
|
|
57
|
+
"evidence": self.evidence,
|
|
58
|
+
"caveats": self.caveats,
|
|
59
|
+
"coveredByExisting": self.covered_by,
|
|
60
|
+
"extendsExisting": self.extends,
|
|
61
|
+
"validation": self.validation,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Existing index / schema loading
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def load_indexes(path: str) -> Dict[str, List[dict]]:
|
|
70
|
+
"""Load existing indexes.
|
|
71
|
+
|
|
72
|
+
Accepts either the `mdbkit export-script indexes` output:
|
|
73
|
+
{"db": "shop", "collections": {"orders": [ ...getIndexes()... ]}}
|
|
74
|
+
or a raw getIndexes() array (then the caller must map it to a namespace).
|
|
75
|
+
Returns {namespace: [ {name, key(ordered pairs)} ]}.
|
|
76
|
+
"""
|
|
77
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
78
|
+
data = json.load(fh)
|
|
79
|
+
result: Dict[str, List[dict]] = {}
|
|
80
|
+
if isinstance(data, dict) and "collections" in data:
|
|
81
|
+
db = data.get("db", "")
|
|
82
|
+
for coll, idx_list in (data.get("collections") or {}).items():
|
|
83
|
+
ns = f"{db}.{coll}" if db else coll
|
|
84
|
+
result[ns] = _normalize_indexes(idx_list)
|
|
85
|
+
elif isinstance(data, list):
|
|
86
|
+
result["*"] = _normalize_indexes(data)
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _normalize_indexes(idx_list) -> List[dict]:
|
|
91
|
+
out = []
|
|
92
|
+
for idx in idx_list or []:
|
|
93
|
+
if not isinstance(idx, dict):
|
|
94
|
+
continue
|
|
95
|
+
key = idx.get("key") or {}
|
|
96
|
+
out.append({
|
|
97
|
+
"name": idx.get("name", ""),
|
|
98
|
+
"key": [(f, _dir(v)) for f, v in key.items()],
|
|
99
|
+
"unique": bool(idx.get("unique")),
|
|
100
|
+
"partial": "partialFilterExpression" in idx,
|
|
101
|
+
"sparse": bool(idx.get("sparse")),
|
|
102
|
+
"ttl": "expireAfterSeconds" in idx,
|
|
103
|
+
})
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _dir(v) -> int:
|
|
108
|
+
try:
|
|
109
|
+
return int(v)
|
|
110
|
+
except (TypeError, ValueError):
|
|
111
|
+
return 0 # hashed / text / 2dsphere etc.
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_schema(path: str) -> Dict[str, dict]:
|
|
115
|
+
"""Load the `mdbkit export-script schema` output.
|
|
116
|
+
|
|
117
|
+
Returns {namespace: {fieldPath: {"types": [...], "presence": float}}}.
|
|
118
|
+
"""
|
|
119
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
120
|
+
data = json.load(fh)
|
|
121
|
+
result: Dict[str, dict] = {}
|
|
122
|
+
db = data.get("db", "")
|
|
123
|
+
for coll, info in (data.get("collections") or {}).items():
|
|
124
|
+
ns = f"{db}.{coll}" if db else coll
|
|
125
|
+
result[ns] = (info or {}).get("fields", {}) or {}
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# Advice
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def _covers(existing_key: List[Tuple[str, int]], candidate: List[Tuple[str, int]]) -> bool:
|
|
134
|
+
"""True if `existing_key` starts with the full candidate (same or fully
|
|
135
|
+
reversed directions — a reversed index serves the mirrored sort)."""
|
|
136
|
+
if len(existing_key) < len(candidate):
|
|
137
|
+
return False
|
|
138
|
+
prefix = existing_key[: len(candidate)]
|
|
139
|
+
same = all(a == b for a, b in zip(prefix, candidate))
|
|
140
|
+
mirrored = all(
|
|
141
|
+
a[0] == b[0] and a[1] == -b[1] and a[1] != 0 for a, b in zip(prefix, candidate)
|
|
142
|
+
)
|
|
143
|
+
return same or mirrored
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def advise(
|
|
147
|
+
shapes: List[ShapeStats],
|
|
148
|
+
indexes: Optional[Dict[str, List[dict]]] = None,
|
|
149
|
+
schema: Optional[Dict[str, dict]] = None,
|
|
150
|
+
min_count: int = 1,
|
|
151
|
+
) -> List[Recommendation]:
|
|
152
|
+
indexes = indexes or {}
|
|
153
|
+
schema = schema or {}
|
|
154
|
+
recs: List[Recommendation] = []
|
|
155
|
+
|
|
156
|
+
for stats in shapes:
|
|
157
|
+
if stats.count < min_count:
|
|
158
|
+
continue
|
|
159
|
+
needs_help = stats.collscan or stats.in_memory_sort or stats.scan_ratio > 10
|
|
160
|
+
if not needs_help:
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
shape = stats.shape
|
|
164
|
+
caveats: List[str] = []
|
|
165
|
+
evidence: List[str] = []
|
|
166
|
+
|
|
167
|
+
equality: List[str] = []
|
|
168
|
+
ranges: List[str] = []
|
|
169
|
+
for path, ops in shape.filter_fields:
|
|
170
|
+
ops_set = set(ops)
|
|
171
|
+
if ops_set & LOW_SELECTIVITY_OPS and not (ops_set & (EQUALITY_OPS | RANGE_OPS)):
|
|
172
|
+
caveats.append(
|
|
173
|
+
f"'{path}' is only used with low-selectivity operators "
|
|
174
|
+
f"({', '.join(sorted(ops_set & LOW_SELECTIVITY_OPS))}); "
|
|
175
|
+
"excluded from the candidate — an index rarely helps there."
|
|
176
|
+
)
|
|
177
|
+
continue
|
|
178
|
+
if ops_set & EQUALITY_OPS or (not ops_set):
|
|
179
|
+
equality.append(path)
|
|
180
|
+
if "$in" in ops_set:
|
|
181
|
+
caveats.append(
|
|
182
|
+
f"'{path}' uses $in: fine for small lists, but very large "
|
|
183
|
+
"$in arrays reduce index effectiveness."
|
|
184
|
+
)
|
|
185
|
+
elif ops_set & RANGE_OPS:
|
|
186
|
+
ranges.append(path)
|
|
187
|
+
elif "$regex" in ops_set:
|
|
188
|
+
ranges.append(path)
|
|
189
|
+
caveats.append(
|
|
190
|
+
f"'{path}' uses $regex: an index only helps if the pattern is "
|
|
191
|
+
"left-anchored (e.g. /^abc/) and case-sensitive."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
sort_fields = [(p, d) for p, d in shape.sort_fields if p not in equality]
|
|
195
|
+
|
|
196
|
+
candidate: List[Tuple[str, int]] = [(f, 1) for f in equality]
|
|
197
|
+
candidate += sort_fields
|
|
198
|
+
candidate += [(f, 1) for f in ranges if f not in {p for p, _ in candidate}]
|
|
199
|
+
|
|
200
|
+
if not candidate:
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
# Evidence trail.
|
|
204
|
+
if stats.collscan:
|
|
205
|
+
evidence.append("COLLSCAN observed in planSummary")
|
|
206
|
+
if stats.in_memory_sort:
|
|
207
|
+
evidence.append("in-memory sort (hasSortStage) observed")
|
|
208
|
+
if stats.n_returned:
|
|
209
|
+
evidence.append(
|
|
210
|
+
f"examined {stats.docs_examined:,} docs to return "
|
|
211
|
+
f"{stats.n_returned:,} ({stats.scan_ratio:.0f}:1)"
|
|
212
|
+
)
|
|
213
|
+
evidence.append(
|
|
214
|
+
f"seen {stats.count}x, total {stats.total_ms:,} ms, max {stats.max_ms:,} ms"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Confidence.
|
|
218
|
+
if stats.collscan and stats.scan_ratio > 100:
|
|
219
|
+
confidence = "high"
|
|
220
|
+
elif stats.collscan or stats.in_memory_sort or stats.scan_ratio > 10:
|
|
221
|
+
confidence = "medium"
|
|
222
|
+
else:
|
|
223
|
+
confidence = "low"
|
|
224
|
+
if stats.count < 3:
|
|
225
|
+
confidence = "low" if confidence == "medium" else confidence
|
|
226
|
+
caveats.append(
|
|
227
|
+
f"Shape seen only {stats.count} time(s); confirm it is recurring "
|
|
228
|
+
"before creating an index for it."
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Flags from the shape.
|
|
232
|
+
if "$or" in shape.flags:
|
|
233
|
+
caveats.append(
|
|
234
|
+
"Query contains $or: MongoDB may need a separate index per $or "
|
|
235
|
+
"branch; this candidate covers the merged field set only."
|
|
236
|
+
)
|
|
237
|
+
for flag in shape.flags:
|
|
238
|
+
if flag in ("$text", "$where", "$expr"):
|
|
239
|
+
caveats.append(f"Query uses {flag}, which this rule engine does not model.")
|
|
240
|
+
|
|
241
|
+
# Schema-aware caveats.
|
|
242
|
+
ns_schema = schema.get(shape.ns) or {}
|
|
243
|
+
for f, _d in candidate:
|
|
244
|
+
info = ns_schema.get(f)
|
|
245
|
+
if info is None and ns_schema:
|
|
246
|
+
caveats.append(
|
|
247
|
+
f"Field '{f}' was not seen in the sampled schema — check for typos."
|
|
248
|
+
)
|
|
249
|
+
elif info:
|
|
250
|
+
if "array" in info.get("types", []):
|
|
251
|
+
caveats.append(
|
|
252
|
+
f"'{f}' is an array field: this becomes a multikey index "
|
|
253
|
+
"(larger, and compound bounds behave differently)."
|
|
254
|
+
)
|
|
255
|
+
if info.get("types") == ["bool"]:
|
|
256
|
+
caveats.append(
|
|
257
|
+
f"'{f}' is boolean — very low cardinality; place it only "
|
|
258
|
+
"alongside more selective fields."
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
caveats.append(
|
|
262
|
+
"Every index adds write and storage overhead and takes time to build; "
|
|
263
|
+
"create with { background/rolling build } strategy appropriate to your "
|
|
264
|
+
"version and validate on staging first."
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Overlap with existing indexes.
|
|
268
|
+
covered_by = extends = None
|
|
269
|
+
for idx in indexes.get(shape.ns, []) + indexes.get("*", []):
|
|
270
|
+
if _covers(idx["key"], candidate):
|
|
271
|
+
covered_by = idx["name"] or str(idx["key"])
|
|
272
|
+
break
|
|
273
|
+
if idx["key"] and _covers(candidate, idx["key"]):
|
|
274
|
+
extends = idx["name"] or str(idx["key"])
|
|
275
|
+
if covered_by:
|
|
276
|
+
evidence.append(
|
|
277
|
+
f"an existing index ('{covered_by}') already has this candidate as "
|
|
278
|
+
"a prefix — the query may be failing to use it; check with explain()."
|
|
279
|
+
)
|
|
280
|
+
if extends:
|
|
281
|
+
caveats.append(
|
|
282
|
+
f"Candidate extends existing index '{extends}'. If created, the "
|
|
283
|
+
"narrower index may become redundant — investigate (do not drop "
|
|
284
|
+
"automatically)."
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
recs.append(
|
|
288
|
+
Recommendation(
|
|
289
|
+
ns=shape.ns,
|
|
290
|
+
shape=shape.pretty(),
|
|
291
|
+
candidate=candidate,
|
|
292
|
+
confidence=confidence,
|
|
293
|
+
evidence=evidence,
|
|
294
|
+
caveats=caveats,
|
|
295
|
+
covered_by=covered_by,
|
|
296
|
+
extends=extends,
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
order = {"high": 0, "medium": 1, "low": 2}
|
|
301
|
+
recs.sort(key=lambda r: order.get(r.confidence, 3))
|
|
302
|
+
return recs
|