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.
@@ -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")}