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 ADDED
@@ -0,0 +1,3 @@
1
+ """mdbkit — an offline toolkit for MongoDB structured logs and index advice."""
2
+
3
+ __version__ = "0.1.0"
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