databricks-explore 0.5.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.
- databricks_explore/__init__.py +3 -0
- databricks_explore/__main__.py +6 -0
- databricks_explore/app.py +673 -0
- databricks_explore/canvases.py +333 -0
- databricks_explore/cli.py +80 -0
- databricks_explore/concepts.py +325 -0
- databricks_explore/dbx.py +537 -0
- databricks_explore/demo.py +653 -0
- databricks_explore/demo_history.py +157 -0
- databricks_explore/genie.py +281 -0
- databricks_explore/layout.py +1764 -0
- databricks_explore/mcp_server/__init__.py +93 -0
- databricks_explore/mcp_server/auth.py +95 -0
- databricks_explore/mcp_server/rpc.py +88 -0
- databricks_explore/mcp_server/schema.py +68 -0
- databricks_explore/mcp_server/sqlguard.py +59 -0
- databricks_explore/mcp_server/tooling.py +74 -0
- databricks_explore/mcp_server/tools_canvas.py +430 -0
- databricks_explore/mcp_server/tools_data.py +472 -0
- databricks_explore/mining.py +433 -0
- databricks_explore/ontology.py +508 -0
- databricks_explore/relationships.py +987 -0
- databricks_explore/sqltools.py +525 -0
- databricks_explore/web_dist/THIRD-PARTY-NOTICES.txt +5853 -0
- databricks_explore/web_dist/assets/index-6I1cPS7i.css +1 -0
- databricks_explore/web_dist/assets/index-CR9j5CJG.js +330 -0
- databricks_explore/web_dist/index.html +13 -0
- databricks_explore-0.5.0.dist-info/METADATA +207 -0
- databricks_explore-0.5.0.dist-info/RECORD +32 -0
- databricks_explore-0.5.0.dist-info/WHEEL +4 -0
- databricks_explore-0.5.0.dist-info/entry_points.txt +2 -0
- databricks_explore-0.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Query-history mining: join pairs and table usage from system.query.history, one explicit run at a time.
|
|
2
|
+
|
|
3
|
+
A run reads finished statements (and the tables each read, from system.access.table_lineage) for a time window
|
|
4
|
+
through the selected SQL warehouse, extracts the column pairs they join on (sqltools.join_pairs) and stores them
|
|
5
|
+
as `join_mined` edges with status "proposed". It never stores statement text or user names: only column names,
|
|
6
|
+
counts, dates and up to 5 statement ids per edge. The state that makes runs incremental (the last time mined, and
|
|
7
|
+
the usage summary) is a small JSON file next to the profile's relationship store.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
from collections import defaultdict, deque
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
from itertools import combinations
|
|
16
|
+
|
|
17
|
+
from pydantic import ValidationError
|
|
18
|
+
|
|
19
|
+
from . import dbx
|
|
20
|
+
from . import relationships
|
|
21
|
+
from . import sqltools
|
|
22
|
+
|
|
23
|
+
WINDOW_DAYS = 30
|
|
24
|
+
MAX_WINDOW_DAYS = 365 # system.query.history keeps a year
|
|
25
|
+
ROW_CAP = 5000 # statements read per run
|
|
26
|
+
SETTLE = timedelta(hours=1) # history rows usually appear within an hour, so the newest hour is left for next time
|
|
27
|
+
TEXT_LIMIT = 100_000 # characters of statement text read; a longer statement will not parse and is counted as such
|
|
28
|
+
STATEMENT_TYPES = ("SELECT", "INSERT", "MERGE", "CREATE")
|
|
29
|
+
REDACTED = "<REDACTED>"
|
|
30
|
+
SAMPLE_REFS = 5
|
|
31
|
+
MAX_PAIR_TABLES = 25 # a statement that reads more tables than this adds none to the table-pair usage
|
|
32
|
+
KEEP_USAGE = 500 # usage rows kept per list between runs
|
|
33
|
+
STATE_VERSION = 2 # 2: edges carry composite keys (evidence.together). A state without it, or older, is re-mined whole.
|
|
34
|
+
REMINE_REASON = ("The last run was made before composite-key support, so this run re-reads the whole window "
|
|
35
|
+
"to add composite keys to the suggestions you already have.")
|
|
36
|
+
HIDDEN_CATALOGS = ("system", "__databricks_internal") # joins between Databricks' own tables are not the user's model
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MiningInvalid(ValueError):
|
|
40
|
+
"""The request cannot run (a bad window, no warehouse). Routes return HTTP 400."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MiningBusy(RuntimeError):
|
|
44
|
+
"""A run is already in progress. Routes return HTTP 409."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Redacted(RuntimeError):
|
|
48
|
+
"""The warehouse returned statement text as <REDACTED>, so there is nothing to mine."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------- the queries ----------
|
|
52
|
+
|
|
53
|
+
def _history_filter():
|
|
54
|
+
types = ", ".join(f"'{t}'" for t in STATEMENT_TYPES)
|
|
55
|
+
return (f"start_time >= CAST(:since AS TIMESTAMP) AND start_time < CAST(:until AS TIMESTAMP)\n"
|
|
56
|
+
f" AND execution_status = 'FINISHED' AND statement_type IN ({types})")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def history_sql(row_cap=ROW_CAP):
|
|
60
|
+
"""The statements to read: finished, SELECT-like, oldest first, capped."""
|
|
61
|
+
return (f"SELECT statement_id, executed_by, start_time, left(statement_text, {TEXT_LIMIT}) AS statement_text\n"
|
|
62
|
+
f"FROM system.query.history\nWHERE {_history_filter()}\n"
|
|
63
|
+
f"ORDER BY start_time, statement_id\nLIMIT {int(row_cap)}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def lineage_sql(row_cap=ROW_CAP):
|
|
67
|
+
"""The tables each of those statements read, by statement_id (SQL warehouse and serverless runs have one)."""
|
|
68
|
+
return (f"WITH q AS (\n SELECT statement_id FROM system.query.history\n WHERE {_history_filter()}\n"
|
|
69
|
+
f" ORDER BY start_time, statement_id\n LIMIT {int(row_cap)})\n"
|
|
70
|
+
"SELECT DISTINCT l.statement_id, l.source_table_full_name\n"
|
|
71
|
+
"FROM system.access.table_lineage l JOIN q ON l.statement_id = q.statement_id\n"
|
|
72
|
+
"WHERE l.source_table_full_name IS NOT NULL\n"
|
|
73
|
+
" AND l.event_time >= CAST(:since AS TIMESTAMP) AND l.event_time < CAST(:until AS TIMESTAMP) + INTERVAL 1 DAY")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------- time ----------
|
|
77
|
+
|
|
78
|
+
def _utcnow():
|
|
79
|
+
return datetime.now(timezone.utc)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_ts(value):
|
|
83
|
+
"""A warehouse or ISO timestamp as an aware UTC datetime. Raises ValueError."""
|
|
84
|
+
text = str(value).strip().replace(" ", "T")
|
|
85
|
+
if text.endswith("Z"):
|
|
86
|
+
text = text[:-1] + "+00:00"
|
|
87
|
+
dt = datetime.fromisoformat(text)
|
|
88
|
+
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _z(dt):
|
|
92
|
+
"""UTC ISO text with a Z, with microseconds only when there are some."""
|
|
93
|
+
dt = dt.astimezone(timezone.utc)
|
|
94
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S" + (".%f" if dt.microsecond else "") + "Z")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------- state (per profile, next to the relationship store) ----------
|
|
98
|
+
|
|
99
|
+
def state_path(profile):
|
|
100
|
+
"""data/relationships/<profile>.mining.json."""
|
|
101
|
+
return dbx.DATA / "relationships" / f"{dbx.safe(profile)}.mining.json"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load_state(profile):
|
|
105
|
+
"""The mining state for a profile: cursor, last run and usage. Empty when nothing was mined yet."""
|
|
106
|
+
try:
|
|
107
|
+
return json.loads(state_path(profile).read_text(encoding="utf-8"))
|
|
108
|
+
except (OSError, ValueError):
|
|
109
|
+
return {}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _save_state(profile, state):
|
|
113
|
+
path = state_path(profile)
|
|
114
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
tmp = path.with_suffix(".tmp")
|
|
116
|
+
tmp.write_text(json.dumps(state, indent=1), encoding="utf-8")
|
|
117
|
+
os.replace(tmp, path)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------- plan ----------
|
|
121
|
+
|
|
122
|
+
def plan(profile, window_days=WINDOW_DAYS, full=False, row_cap=ROW_CAP, now=None):
|
|
123
|
+
"""What a run would do, without running anything: the time window and the SQL, with its parameters.
|
|
124
|
+
|
|
125
|
+
The first run (or `full`) reads the last `window_days` days. A later run is incremental: it starts where the
|
|
126
|
+
last one stopped and `window_days` does not apply. The newest hour is left out, since rows may not be in yet.
|
|
127
|
+
A state written by an older format (no `version`, or below STATE_VERSION) is not continued: the run reads the whole
|
|
128
|
+
window and replaces the stored evidence, and `remine_reason` says why (it is None otherwise).
|
|
129
|
+
"""
|
|
130
|
+
if isinstance(window_days, bool) or not isinstance(window_days, int) or not 1 <= window_days <= MAX_WINDOW_DAYS:
|
|
131
|
+
raise MiningInvalid(f"window_days: must be a whole number from 1 to {MAX_WINDOW_DAYS}")
|
|
132
|
+
if isinstance(row_cap, bool) or not isinstance(row_cap, int) or row_cap < 1:
|
|
133
|
+
raise MiningInvalid("row_cap: must be a positive whole number")
|
|
134
|
+
state = load_state(profile)
|
|
135
|
+
until = ((now or _utcnow()) - SETTLE).replace(microsecond=0)
|
|
136
|
+
cursor = None
|
|
137
|
+
outdated = bool(state.get("cursor")) and (state.get("version") or 0) < STATE_VERSION
|
|
138
|
+
if state.get("cursor") and not full and not outdated:
|
|
139
|
+
try:
|
|
140
|
+
cursor = _parse_ts(state["cursor"])
|
|
141
|
+
except ValueError:
|
|
142
|
+
cursor = None
|
|
143
|
+
since = min(cursor or until - timedelta(days=window_days), until)
|
|
144
|
+
params = {"since": _z(since), "until": _z(until)}
|
|
145
|
+
return {
|
|
146
|
+
"since": params["since"], "until": params["until"], "window_days": window_days,
|
|
147
|
+
"incremental": cursor is not None, "cursor": _z(cursor) if cursor else None, "row_cap": row_cap,
|
|
148
|
+
"remine_reason": REMINE_REASON if outdated and not full else None,
|
|
149
|
+
"queries": [{"name": "query_history", "sql": history_sql(row_cap), "params": params},
|
|
150
|
+
{"name": "table_lineage", "sql": lineage_sql(row_cap), "params": params}],
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------- the run ----------
|
|
155
|
+
|
|
156
|
+
class _Pair:
|
|
157
|
+
"""Evidence for one column pair while a run is reading statements."""
|
|
158
|
+
|
|
159
|
+
def __init__(self):
|
|
160
|
+
self.runs = 0
|
|
161
|
+
self.users = set()
|
|
162
|
+
self.first = self.last = None
|
|
163
|
+
self.refs = deque(maxlen=SAMPLE_REFS)
|
|
164
|
+
self.together = defaultdict(int) # composite key (its column pairs) -> statements that joined on it
|
|
165
|
+
|
|
166
|
+
def add(self, sid, user, day):
|
|
167
|
+
self.runs += 1
|
|
168
|
+
if user:
|
|
169
|
+
self.users.add(user)
|
|
170
|
+
self.first = day if self.first is None or day < self.first else self.first
|
|
171
|
+
self.last = day if self.last is None or day > self.last else self.last
|
|
172
|
+
if sid not in self.refs:
|
|
173
|
+
self.refs.append(sid)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _rows(result):
|
|
177
|
+
names = [c["name"] for c in result["columns"]]
|
|
178
|
+
return [dict(zip(names, r)) for r in result["rows"]]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _read_names(tables):
|
|
182
|
+
"""Tables that count as the user's own: three-part names outside the Databricks catalogs."""
|
|
183
|
+
return {t.lower() for t in tables if t and t.count(".") == 2 and t.split(".")[0].lower() not in HIDDEN_CATALOGS}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _declared_fks(ex, table):
|
|
187
|
+
"""Column pairs declared as foreign keys on a table, as keys. Empty when the table cannot be read."""
|
|
188
|
+
try:
|
|
189
|
+
info = ex.object(table)["data"]
|
|
190
|
+
except Exception:
|
|
191
|
+
return None
|
|
192
|
+
out = set()
|
|
193
|
+
for con in info.get("table_constraints") or []:
|
|
194
|
+
fk = con.get("foreign_key_constraint")
|
|
195
|
+
if not fk:
|
|
196
|
+
continue
|
|
197
|
+
for child, parent in zip(fk.get("child_columns") or [], fk.get("parent_columns") or []):
|
|
198
|
+
out.add(_key(table, child, fk["parent_table"], parent))
|
|
199
|
+
return out
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _key(t1, c1, t2, c2):
|
|
203
|
+
a, b = (t1.lower(), c1.lower()), (t2.lower(), c2.lower())
|
|
204
|
+
return (*a, *b) if a <= b else (*b, *a)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def run(ex, p, progress=lambda phase, done=0, total=0: None):
|
|
208
|
+
"""Execute a plan: read, extract, check foreign keys, store. Returns the result counts.
|
|
209
|
+
|
|
210
|
+
Raises Redacted when the statement text is hidden from this reader, before anything is stored or the
|
|
211
|
+
cursor moves. Any warehouse error propagates (insufficient privileges on the system tables, for one).
|
|
212
|
+
"""
|
|
213
|
+
quiet = logging.getLogger("sqlglot") # it warns about every statement it falls back to a Command for
|
|
214
|
+
level, quiet.level = quiet.level, logging.ERROR
|
|
215
|
+
try:
|
|
216
|
+
return _run(ex, p, progress)
|
|
217
|
+
finally:
|
|
218
|
+
quiet.level = level
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _run(ex, p, progress):
|
|
222
|
+
since, until, cap = p["since"], p["until"], p["row_cap"]
|
|
223
|
+
state = load_state(ex.profile)
|
|
224
|
+
skip_ids = set(state.get("cursor_ids") or []) if p["incremental"] else set()
|
|
225
|
+
q_hist, q_lin = p["queries"]
|
|
226
|
+
|
|
227
|
+
progress("history")
|
|
228
|
+
history = _rows(ex.query(q_hist["sql"], q_hist["params"], max_rows=cap))
|
|
229
|
+
capped = len(history) >= cap
|
|
230
|
+
redacted = sum(1 for r in history if (r.get("statement_text") or "").strip() == REDACTED)
|
|
231
|
+
if history and redacted * 2 >= len(history):
|
|
232
|
+
raise Redacted(f"statement_text came back as {REDACTED} for {redacted} of {len(history)} statements")
|
|
233
|
+
rows = [r for r in history if r["statement_id"] not in skip_ids]
|
|
234
|
+
|
|
235
|
+
progress("lineage")
|
|
236
|
+
lineage = defaultdict(set)
|
|
237
|
+
if rows:
|
|
238
|
+
for r in _rows(ex.query(q_lin["sql"], q_lin["params"], max_rows=cap * 50)):
|
|
239
|
+
lineage[r["statement_id"]].add(r["source_table_full_name"])
|
|
240
|
+
|
|
241
|
+
pairs = defaultdict(_Pair)
|
|
242
|
+
users_by_table, runs_by_table = defaultdict(set), defaultdict(int)
|
|
243
|
+
users_by_pair, runs_by_pair = defaultdict(set), defaultdict(int)
|
|
244
|
+
parsed = with_joins = unparsed = redacted_skipped = no_lineage = 0
|
|
245
|
+
for i, r in enumerate(rows):
|
|
246
|
+
progress("extract", i, len(rows))
|
|
247
|
+
text, sid, user = r.get("statement_text") or "", r["statement_id"], r.get("executed_by")
|
|
248
|
+
if text.strip() == REDACTED or not text.strip():
|
|
249
|
+
redacted_skipped += 1
|
|
250
|
+
continue
|
|
251
|
+
read = _read_names(lineage.get(sid, ()))
|
|
252
|
+
no_lineage += not lineage.get(sid)
|
|
253
|
+
try:
|
|
254
|
+
found, composites = sqltools.join_analysis(text, known=lineage.get(sid))
|
|
255
|
+
if not read:
|
|
256
|
+
read = _read_names(_safe_tables(text))
|
|
257
|
+
except ValueError:
|
|
258
|
+
unparsed += 1
|
|
259
|
+
continue
|
|
260
|
+
parsed += 1
|
|
261
|
+
day = str(r["start_time"])[:10]
|
|
262
|
+
for t in read:
|
|
263
|
+
runs_by_table[t] += 1
|
|
264
|
+
if user:
|
|
265
|
+
users_by_table[t].add(user)
|
|
266
|
+
if 2 <= len(read) <= MAX_PAIR_TABLES:
|
|
267
|
+
for a, b in combinations(sorted(read), 2):
|
|
268
|
+
runs_by_pair[(a, b)] += 1
|
|
269
|
+
if user:
|
|
270
|
+
users_by_pair[(a, b)].add(user)
|
|
271
|
+
mine = [j for j in found if not {j.left_table.split(".")[0], j.right_table.split(".")[0]} & set(HIDDEN_CATALOGS)]
|
|
272
|
+
with_joins += bool(mine)
|
|
273
|
+
for j in mine:
|
|
274
|
+
pairs[_key(j.left_table, j.left_column, j.right_table, j.right_column)].add(sid, user, day)
|
|
275
|
+
for keys in composites:
|
|
276
|
+
members = [_key(*k) for k in keys]
|
|
277
|
+
if all(k in pairs for k in members): # all of them, so none is in a Databricks catalog
|
|
278
|
+
columns = tuple((k[1], k[3]) for k in members)
|
|
279
|
+
for k in members:
|
|
280
|
+
pairs[k].together[columns] += 1
|
|
281
|
+
progress("extract", len(rows), len(rows))
|
|
282
|
+
|
|
283
|
+
# Pairs that are declared foreign keys are not suggestions.
|
|
284
|
+
tables = sorted({t for k in pairs for t in (k[0], k[2])})
|
|
285
|
+
declared, unreadable = set(), 0
|
|
286
|
+
for i, t in enumerate(tables):
|
|
287
|
+
progress("foreign_keys", i, len(tables))
|
|
288
|
+
fks = _declared_fks(ex, t)
|
|
289
|
+
if fks is None:
|
|
290
|
+
unreadable += 1
|
|
291
|
+
else:
|
|
292
|
+
declared |= fks
|
|
293
|
+
skipped_fk = sum(1 for k in pairs if k in declared)
|
|
294
|
+
|
|
295
|
+
progress("save")
|
|
296
|
+
fetched = dbx.now()
|
|
297
|
+
edges, invalid = [], 0
|
|
298
|
+
for k, ev in pairs.items():
|
|
299
|
+
if k in declared:
|
|
300
|
+
continue
|
|
301
|
+
try:
|
|
302
|
+
edges.append(relationships.Edge(
|
|
303
|
+
kind="join_mined", directed=False, status="proposed",
|
|
304
|
+
source=relationships.Endpoint(table=k[0], column=k[1]),
|
|
305
|
+
target=relationships.Endpoint(table=k[2], column=k[3]),
|
|
306
|
+
provenance=relationships.Provenance(origin="query_history", source_ref=ev.refs[-1], fetched_at=fetched),
|
|
307
|
+
evidence=relationships.Evidence(runs=ev.runs, users=len(ev.users), first_seen=ev.first, last_seen=ev.last,
|
|
308
|
+
sample_refs=list(reversed(ev.refs)), together=_together(ev))))
|
|
309
|
+
except ValidationError:
|
|
310
|
+
invalid += 1
|
|
311
|
+
with relationships.open_store(ex.profile) as store:
|
|
312
|
+
created, updated = store.upsert_mined(edges, replace=not p["incremental"] and bool(state.get("cursor")))
|
|
313
|
+
|
|
314
|
+
usage = _merge_usage(state.get("usage"), p, len(rows), runs_by_table, users_by_table, runs_by_pair, users_by_pair)
|
|
315
|
+
if capped and history:
|
|
316
|
+
stop = history[-1]["start_time"]
|
|
317
|
+
cursor, cursor_ids = _z(_parse_ts(stop)), [r["statement_id"] for r in history if r["start_time"] == stop]
|
|
318
|
+
else:
|
|
319
|
+
cursor, cursor_ids = until, []
|
|
320
|
+
result = {"statements": len(rows), "parsed": parsed, "with_joins": with_joins, "unparsed": unparsed,
|
|
321
|
+
"redacted_skipped": redacted_skipped, "no_lineage": no_lineage, "pairs": len(edges),
|
|
322
|
+
"skipped_fk": skipped_fk, "fk_unreadable_tables": unreadable, "invalid": invalid,
|
|
323
|
+
"created": created, "updated": updated, "capped": capped, "since": since,
|
|
324
|
+
"until": cursor if capped else until, "finished_at": dbx.now()}
|
|
325
|
+
_save_state(ex.profile, {"version": STATE_VERSION, "cursor": cursor, "cursor_ids": cursor_ids, "last_run": result, "usage": usage})
|
|
326
|
+
return result
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _together(ev):
|
|
330
|
+
"""The composite keys a pair was part of, most runs first."""
|
|
331
|
+
top = sorted(ev.together.items(), key=lambda kv: (-kv[1], kv[0]))[:relationships.MAX_TOGETHER]
|
|
332
|
+
return [relationships.Together(columns=list(cols), runs=n) for cols, n in top]
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _safe_tables(text):
|
|
336
|
+
"""Fully qualified table names a statement reads, or none when sqlglot cannot say."""
|
|
337
|
+
try:
|
|
338
|
+
return sqltools.tables_read(text)
|
|
339
|
+
except ValueError:
|
|
340
|
+
return []
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ---------- usage summary ----------
|
|
344
|
+
|
|
345
|
+
def _top(counts, users, keep):
|
|
346
|
+
rows = sorted(((k, c, len(users.get(k, ()))) for k, c in counts.items()), key=lambda x: (-x[1], -x[2], x[0]))
|
|
347
|
+
return rows[:keep]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _merge_usage(old, p, statements, runs_by_table, users_by_table, runs_by_pair, users_by_pair):
|
|
351
|
+
"""The usage summary kept in state. Counts of an incremental run are added to the earlier ones, with the
|
|
352
|
+
larger of the two distinct-user counts (users are not kept, so across runs that is a lower bound)."""
|
|
353
|
+
tables = {t: [r, u] for t, r, u in _top(runs_by_table, users_by_table, KEEP_USAGE)}
|
|
354
|
+
pairs = {"\t".join(k): [r, u] for k, r, u in _top(runs_by_pair, users_by_pair, KEEP_USAGE)}
|
|
355
|
+
since = p["since"]
|
|
356
|
+
if p["incremental"] and old:
|
|
357
|
+
for mine, theirs in ((tables, old.get("tables", {})), (pairs, old.get("pairs", {}))):
|
|
358
|
+
for k, (r, u) in theirs.items():
|
|
359
|
+
a = mine.get(k, [0, 0])
|
|
360
|
+
mine[k] = [a[0] + r, max(a[1], u)]
|
|
361
|
+
since = old.get("since", since)
|
|
362
|
+
statements += old.get("statements", 0)
|
|
363
|
+
|
|
364
|
+
def keep(d):
|
|
365
|
+
return dict(sorted(d.items(), key=lambda kv: (-kv[1][0], -kv[1][1], kv[0]))[:KEEP_USAGE])
|
|
366
|
+
return {"since": since, "until": p["until"], "statements": statements, "tables": keep(tables), "pairs": keep(pairs)}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def usage_summary(profile, limit=10):
|
|
370
|
+
"""Top tables by runs and distinct users, and top table pairs read together, for the mined window."""
|
|
371
|
+
u = load_state(profile).get("usage")
|
|
372
|
+
if not u:
|
|
373
|
+
return {"window": None, "statements": 0, "tables": [], "pairs": []}
|
|
374
|
+
return {"window": {"since": u["since"], "until": u["until"]}, "statements": u["statements"],
|
|
375
|
+
"tables": [{"table": t, "runs": r, "users": n} for t, (r, n) in list(u["tables"].items())[:limit]],
|
|
376
|
+
"pairs": [{"tables": k.split("\t"), "runs": r, "users": n} for k, (r, n) in list(u["pairs"].items())[:limit]]}
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# ---------- one run at a time, in the background ----------
|
|
380
|
+
|
|
381
|
+
_IDLE = {"state": "idle", "phase": None, "progress": {"done": 0, "total": 0}, "started_at": None, "finished_at": None,
|
|
382
|
+
"plan": None, "result": None, "error": None, "reason": None}
|
|
383
|
+
|
|
384
|
+
class Miner:
|
|
385
|
+
"""Runs mining in a thread for one connection and reports progress. One run at a time."""
|
|
386
|
+
|
|
387
|
+
def __init__(self, explorer):
|
|
388
|
+
self.ex = explorer
|
|
389
|
+
self.lock = threading.Lock()
|
|
390
|
+
self._status = _IDLE
|
|
391
|
+
|
|
392
|
+
def start(self, window_days=WINDOW_DAYS, full=False, row_cap=ROW_CAP, background=True, now=None):
|
|
393
|
+
"""Start a run. MiningInvalid for a bad request or no warehouse, MiningBusy when one is running."""
|
|
394
|
+
with self.lock:
|
|
395
|
+
if self._status["state"] == "running":
|
|
396
|
+
raise MiningBusy("a mining run is already in progress")
|
|
397
|
+
if not self.ex.warehouse_id:
|
|
398
|
+
raise MiningInvalid("pick a SQL warehouse: mining reads the system tables through it")
|
|
399
|
+
p = plan(self.ex.profile, window_days, full, row_cap, now)
|
|
400
|
+
self._status = {**_IDLE, "state": "running", "phase": "history", "started_at": dbx.now(), "plan": _summary(p)}
|
|
401
|
+
if background:
|
|
402
|
+
threading.Thread(target=self._work, args=(p,), daemon=True).start()
|
|
403
|
+
else:
|
|
404
|
+
self._work(p)
|
|
405
|
+
return self.status()
|
|
406
|
+
|
|
407
|
+
def _progress(self, phase, done=0, total=0):
|
|
408
|
+
with self.lock:
|
|
409
|
+
self._status.update(phase=phase, progress={"done": done, "total": total})
|
|
410
|
+
|
|
411
|
+
def _work(self, p):
|
|
412
|
+
try:
|
|
413
|
+
result = run(self.ex, p, self._progress)
|
|
414
|
+
end = {"state": "succeeded", "result": result}
|
|
415
|
+
except Redacted as e:
|
|
416
|
+
end = {"state": "blocked", "reason": "redacted", "error": (
|
|
417
|
+
f"{e}. Query text is only readable by an account admin or a member of databricks_pii_access, "
|
|
418
|
+
"so nothing was mined and nothing was stored.")}
|
|
419
|
+
except Exception as e: # warehouse errors, missing access to the system tables
|
|
420
|
+
end = {"state": "failed", "error": dbx.clean_error(e)}
|
|
421
|
+
with self.lock:
|
|
422
|
+
self._status.update(end, phase=None, finished_at=dbx.now())
|
|
423
|
+
|
|
424
|
+
def status(self):
|
|
425
|
+
"""The current or latest run in this process, with the last stored run from an earlier one."""
|
|
426
|
+
with self.lock:
|
|
427
|
+
out = dict(self._status)
|
|
428
|
+
out["last_run"] = load_state(self.ex.profile).get("last_run")
|
|
429
|
+
return out
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _summary(p):
|
|
433
|
+
return {k: p[k] for k in ("since", "until", "window_days", "incremental", "row_cap")}
|