|
14 | 14 | import shlex |
15 | 15 | import shutil |
16 | 16 | import subprocess |
| 17 | +import time |
| 18 | +from concurrent.futures import ( |
| 19 | + ThreadPoolExecutor, |
| 20 | + as_completed, |
| 21 | +) |
| 22 | +from concurrent.futures import ( |
| 23 | + TimeoutError as FutureTimeoutError, |
| 24 | +) |
17 | 25 | from pathlib import Path |
18 | 26 | from typing import Literal, cast, overload |
19 | 27 | from urllib import error as urllib_error |
@@ -1251,6 +1259,259 @@ def build_mcp_service_url(workspace: str, full_name: str) -> str: |
1251 | 1259 | return f"{workspace}/ai-gateway/mcp-services/{full_name}" |
1252 | 1260 |
|
1253 | 1261 |
|
| 1262 | +# `list_vector_search_catalog_schemas` walks Vector Search endpoints+indexes. |
| 1263 | +# `list_uc_functions_catalog_schemas` walks UC catalogs+schemas in parallel and |
| 1264 | +# keeps only schemas with at least one user function. |
| 1265 | + |
| 1266 | +_UC_LIST_PAGE_SIZE = 200 |
| 1267 | +_UC_LIST_MAX_PAGES = 50 |
| 1268 | +_UC_FUNCTION_PROBE_WORKERS = 16 |
| 1269 | +_UC_LIST_HTTP_TIMEOUT = 10 |
| 1270 | +_UC_FUNCTION_PROBE_TIMEOUT = 5 |
| 1271 | +_VECTOR_SEARCH_DEADLINE_SECONDS = 15.0 |
| 1272 | +_UC_FUNCTIONS_DEADLINE_SECONDS = 20.0 |
| 1273 | +# Skip UC catalogs whose schemas almost never carry user-callable functions |
| 1274 | +# you'd want to expose as agent tools. |
| 1275 | +_UC_FUNCTIONS_SKIP_CATALOGS = frozenset( |
| 1276 | + {"__databricks_internal", "hive_metastore", "samples", "system"} |
| 1277 | +) |
| 1278 | + |
| 1279 | + |
| 1280 | +def _drain_with_deadline(futures: dict, deadline: float, on_result) -> None: |
| 1281 | + """Iterate `futures` via `as_completed`, calling `on_result(value, key)` per |
| 1282 | + completed future, until either all are done or `deadline` passes. Per-task |
| 1283 | + exceptions are swallowed so one failure doesn't stop the rest.""" |
| 1284 | + remaining = max(0.0, deadline - time.monotonic()) |
| 1285 | + try: |
| 1286 | + for future in as_completed(futures, timeout=remaining): |
| 1287 | + try: |
| 1288 | + value = future.result() |
| 1289 | + except Exception: # noqa: BLE001 |
| 1290 | + continue |
| 1291 | + on_result(value, futures[future]) |
| 1292 | + if time.monotonic() > deadline: |
| 1293 | + break |
| 1294 | + except FutureTimeoutError: |
| 1295 | + pass |
| 1296 | + |
| 1297 | + |
| 1298 | +def _paginated_json_items( |
| 1299 | + base_url: str, |
| 1300 | + token: str, |
| 1301 | + *, |
| 1302 | + items_key: str, |
| 1303 | + extra_params: dict[str, str] | None = None, |
| 1304 | + page_size: int = _UC_LIST_PAGE_SIZE, |
| 1305 | + max_pages: int = _UC_LIST_MAX_PAGES, |
| 1306 | + timeout: int = 30, |
| 1307 | +) -> tuple[list[dict], str | None]: |
| 1308 | + """Walk a Databricks `next_page_token` listing and return all items. |
| 1309 | +
|
| 1310 | + Returns (items, reason). Items are dicts; reason is None on success or a |
| 1311 | + short description of why the walk stopped early. |
| 1312 | + """ |
| 1313 | + items: list[dict] = [] |
| 1314 | + page_token: str | None = None |
| 1315 | + seen_tokens: set[str] = set() |
| 1316 | + last_reason: str | None = None |
| 1317 | + for _ in range(max_pages): |
| 1318 | + params: dict[str, str] = {"max_results": str(page_size)} |
| 1319 | + if extra_params: |
| 1320 | + params.update(extra_params) |
| 1321 | + if page_token: |
| 1322 | + params["page_token"] = page_token |
| 1323 | + url = f"{base_url}?{urlencode(params)}" |
| 1324 | + payload, reason = _http_get_json(url, token, timeout=timeout) |
| 1325 | + if payload is None: |
| 1326 | + last_reason = reason |
| 1327 | + break |
| 1328 | + data = cast(dict, payload) if isinstance(payload, dict) else {} |
| 1329 | + raw = data.get(items_key) or [] |
| 1330 | + if isinstance(raw, list): |
| 1331 | + for item in raw: |
| 1332 | + if isinstance(item, dict): |
| 1333 | + items.append(item) |
| 1334 | + page_token = data.get("next_page_token") or None |
| 1335 | + if not page_token or page_token in seen_tokens: |
| 1336 | + break |
| 1337 | + seen_tokens.add(page_token) |
| 1338 | + return items, last_reason |
| 1339 | + |
| 1340 | + |
| 1341 | +def _vector_index_catalog_schema(index: dict) -> tuple[str, str] | None: |
| 1342 | + """Pull (catalog, schema) from one vector-search index entry.""" |
| 1343 | + catalog = index.get("catalog_name") |
| 1344 | + schema = index.get("schema_name") |
| 1345 | + if isinstance(catalog, str) and isinstance(schema, str) and catalog and schema: |
| 1346 | + return catalog, schema |
| 1347 | + # Fallback: `name` is the fully-qualified UC name `catalog.schema.index`. |
| 1348 | + name = index.get("name") |
| 1349 | + if isinstance(name, str): |
| 1350 | + parts = name.split(".") |
| 1351 | + if len(parts) >= 3 and parts[0] and parts[1]: |
| 1352 | + return parts[0], parts[1] |
| 1353 | + return None |
| 1354 | + |
| 1355 | + |
| 1356 | +def list_vector_search_catalog_schemas( |
| 1357 | + workspace: str, |
| 1358 | + token: str, |
| 1359 | + *, |
| 1360 | + deadline_seconds: float = _VECTOR_SEARCH_DEADLINE_SECONDS, |
| 1361 | +) -> tuple[list[tuple[str, str]], str | None]: |
| 1362 | + """Return sorted unique `(catalog, schema)` pairs that contain at least |
| 1363 | + one Databricks Vector Search index. Walks the per-endpoint index listings |
| 1364 | + in parallel under a wall-clock budget; returns partial results once |
| 1365 | + `deadline_seconds` is exceeded.""" |
| 1366 | + hostname = workspace_hostname(workspace) |
| 1367 | + deadline = time.monotonic() + deadline_seconds |
| 1368 | + endpoints, reason = _paginated_json_items( |
| 1369 | + f"https://{hostname}/api/2.0/vector-search/endpoints", |
| 1370 | + token, |
| 1371 | + items_key="endpoints", |
| 1372 | + timeout=_UC_LIST_HTTP_TIMEOUT, |
| 1373 | + ) |
| 1374 | + if not endpoints: |
| 1375 | + return [], reason or "no vector search endpoints found" |
| 1376 | + |
| 1377 | + endpoint_names = [e["name"] for e in endpoints if isinstance(e.get("name"), str) and e["name"]] |
| 1378 | + if not endpoint_names: |
| 1379 | + return [], "no vector search endpoints with names" |
| 1380 | + |
| 1381 | + pairs: set[tuple[str, str]] = set() |
| 1382 | + workers = max(1, min(_UC_FUNCTION_PROBE_WORKERS, len(endpoint_names))) |
| 1383 | + with ThreadPoolExecutor(max_workers=workers) as pool: |
| 1384 | + futures = { |
| 1385 | + pool.submit( |
| 1386 | + _paginated_json_items, |
| 1387 | + f"https://{hostname}/api/2.0/vector-search/indexes", |
| 1388 | + token, |
| 1389 | + items_key="vector_indexes", |
| 1390 | + extra_params={"endpoint_name": name}, |
| 1391 | + timeout=_UC_LIST_HTTP_TIMEOUT, |
| 1392 | + ): name |
| 1393 | + for name in endpoint_names |
| 1394 | + } |
| 1395 | + |
| 1396 | + def collect(result, _endpoint): |
| 1397 | + indexes, _ = result |
| 1398 | + for index in indexes: |
| 1399 | + pair = _vector_index_catalog_schema(index) |
| 1400 | + if pair: |
| 1401 | + pairs.add(pair) |
| 1402 | + |
| 1403 | + _drain_with_deadline(futures, deadline, collect) |
| 1404 | + pool.shutdown(wait=False, cancel_futures=True) |
| 1405 | + |
| 1406 | + if not pairs: |
| 1407 | + return [], "no vector search indexes found" |
| 1408 | + return sorted(pairs), None |
| 1409 | + |
| 1410 | + |
| 1411 | +def _schema_has_user_function(hostname: str, token: str, catalog: str, schema: str) -> bool: |
| 1412 | + """One-shot probe: does `{catalog}.{schema}` expose any UC function?""" |
| 1413 | + url = ( |
| 1414 | + f"https://{hostname}/api/2.1/unity-catalog/functions" |
| 1415 | + f"?{urlencode({'catalog_name': catalog, 'schema_name': schema, 'max_results': '1'})}" |
| 1416 | + ) |
| 1417 | + payload, _reason = _http_get_json(url, token, timeout=_UC_FUNCTION_PROBE_TIMEOUT) |
| 1418 | + if not isinstance(payload, dict): |
| 1419 | + return False |
| 1420 | + functions = payload.get("functions") or [] |
| 1421 | + return isinstance(functions, list) and any(isinstance(item, dict) for item in functions) |
| 1422 | + |
| 1423 | + |
| 1424 | +def list_uc_functions_catalog_schemas( |
| 1425 | + workspace: str, |
| 1426 | + token: str, |
| 1427 | + *, |
| 1428 | + deadline_seconds: float = _UC_FUNCTIONS_DEADLINE_SECONDS, |
| 1429 | +) -> tuple[list[tuple[str, str]], str | None]: |
| 1430 | + """Return sorted unique `(catalog, schema)` pairs containing at least one |
| 1431 | + user-defined UC function.""" |
| 1432 | + hostname = workspace_hostname(workspace) |
| 1433 | + deadline = time.monotonic() + deadline_seconds |
| 1434 | + |
| 1435 | + catalogs, catalogs_reason = _paginated_json_items( |
| 1436 | + f"https://{hostname}/api/2.1/unity-catalog/catalogs", |
| 1437 | + token, |
| 1438 | + items_key="catalogs", |
| 1439 | + timeout=_UC_LIST_HTTP_TIMEOUT, |
| 1440 | + ) |
| 1441 | + if not catalogs: |
| 1442 | + return [], catalogs_reason or "no UC catalogs found" |
| 1443 | + |
| 1444 | + catalog_names = [ |
| 1445 | + c["name"] |
| 1446 | + for c in catalogs |
| 1447 | + if isinstance(c.get("name"), str) |
| 1448 | + and c["name"] |
| 1449 | + and c["name"] not in _UC_FUNCTIONS_SKIP_CATALOGS |
| 1450 | + ] |
| 1451 | + if not catalog_names: |
| 1452 | + return [], "no user UC catalogs found" |
| 1453 | + if time.monotonic() > deadline: |
| 1454 | + return [], "deadline exceeded while listing UC catalogs" |
| 1455 | + |
| 1456 | + # Parallel per-catalog schema listing. |
| 1457 | + candidate_pairs: list[tuple[str, str]] = [] |
| 1458 | + schema_workers = max(1, min(_UC_FUNCTION_PROBE_WORKERS, len(catalog_names))) |
| 1459 | + with ThreadPoolExecutor(max_workers=schema_workers) as pool: |
| 1460 | + schema_futures = { |
| 1461 | + pool.submit( |
| 1462 | + _paginated_json_items, |
| 1463 | + f"https://{hostname}/api/2.1/unity-catalog/schemas", |
| 1464 | + token, |
| 1465 | + items_key="schemas", |
| 1466 | + extra_params={"catalog_name": cat}, |
| 1467 | + timeout=_UC_LIST_HTTP_TIMEOUT, |
| 1468 | + ): cat |
| 1469 | + for cat in catalog_names |
| 1470 | + } |
| 1471 | + |
| 1472 | + def collect_schemas(result, catalog): |
| 1473 | + schemas, _ = result |
| 1474 | + for schema in schemas: |
| 1475 | + schema_name = schema.get("name") |
| 1476 | + # `information_schema` is auto-attached to every catalog and |
| 1477 | + # never holds user functions. |
| 1478 | + if ( |
| 1479 | + isinstance(schema_name, str) |
| 1480 | + and schema_name |
| 1481 | + and schema_name != "information_schema" |
| 1482 | + ): |
| 1483 | + candidate_pairs.append((catalog, schema_name)) |
| 1484 | + |
| 1485 | + _drain_with_deadline(schema_futures, deadline, collect_schemas) |
| 1486 | + pool.shutdown(wait=False, cancel_futures=True) |
| 1487 | + |
| 1488 | + if not candidate_pairs: |
| 1489 | + if time.monotonic() > deadline: |
| 1490 | + return [], "deadline exceeded while listing UC schemas" |
| 1491 | + return [], "no UC schemas found" |
| 1492 | + |
| 1493 | + # Parallel function-existence probes. |
| 1494 | + pairs: set[tuple[str, str]] = set() |
| 1495 | + with ThreadPoolExecutor(max_workers=_UC_FUNCTION_PROBE_WORKERS) as pool: |
| 1496 | + probe_futures = { |
| 1497 | + pool.submit(_schema_has_user_function, hostname, token, cat, schema): (cat, schema) |
| 1498 | + for cat, schema in candidate_pairs |
| 1499 | + } |
| 1500 | + |
| 1501 | + def collect_pair(has_fn, pair): |
| 1502 | + if has_fn: |
| 1503 | + pairs.add(pair) |
| 1504 | + |
| 1505 | + _drain_with_deadline(probe_futures, deadline, collect_pair) |
| 1506 | + pool.shutdown(wait=False, cancel_futures=True) |
| 1507 | + |
| 1508 | + if not pairs: |
| 1509 | + if time.monotonic() > deadline: |
| 1510 | + return [], "deadline exceeded probing UC schemas for functions" |
| 1511 | + return [], "no UC schemas with user functions found" |
| 1512 | + return sorted(pairs), None |
| 1513 | + |
| 1514 | + |
1254 | 1515 | def discover_claude_models(workspace: str, token: str) -> tuple[dict[str, str], str | None]: |
1255 | 1516 | """Discover Claude families on this workspace's AI Gateway. |
1256 | 1517 |
|
|
0 commit comments