Skip to content

Commit a03df6d

Browse files
xsh310Isaac
andauthored
Extract walk_catalog_schemas scaffold in databricks.py (#574)
## What changed, and why? **Change:** Extract the catalogs → schemas → parallel-per-schema-probe walk out of `list_all_mcp_services` into a generic `walk_catalog_schemas[T](*, deadline, probe, collect, skip_catalogs=…)` in `databricks.py`, and rewrite `list_all_mcp_services` on top of it. The scaffold owns catalog/schema enumeration, the worker pools, and the wall-clock deadline drain; the caller supplies the `probe` (`list_mcp_services`) and a `collect` that owns accumulation, dedup, progress, and streaming. **Why:** Pure refactor (PR B in the `ug skill add` interactive-picker stack). Extracting the walk lets PR D's skills discovery reuse it as a second caller. `list_all_mcp_services`'s signature and every return value are unchanged. Note: the design doc's PR B also mentioned rewriting `list_uc_functions_catalog_schemas`, but that function does not exist in this repo — there is a single walk today, so the scaffold has one caller now and the skills walk becomes the second in PR D. ## How do you know it works? **Testing:** `ruff check` and `ruff format` clean; full `uv run pytest` green except the two pre-existing e2e failures (`test_e2e_user_agent`, `test_claude_smart_routing_v2`) that also fail on `main`. Added `TestWalkCatalogSchemas` (skips skip-catalogs + `information_schema`, probes each user schema, reports progress, returns the right phase-1 reason); the existing `TestListAllMcpServices` tests stay green unchanged. This pull request and its description were written by Isaac. --------- Co-authored-by: Isaac <no-reply@databricks.com>
1 parent 121b45f commit a03df6d

2 files changed

Lines changed: 146 additions & 47 deletions

File tree

src/ucode/databricks.py

Lines changed: 80 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2439,7 +2439,7 @@ def resolve_provider_launch_model(model: str | None, provider_models: dict[str,
24392439

24402440
_UC_LIST_PAGE_SIZE = 200
24412441
_UC_LIST_MAX_PAGES = 50
2442-
_UC_FUNCTION_PROBE_WORKERS = 16
2442+
_SCHEMA_PROBE_WORKERS = 16
24432443
_UC_LIST_HTTP_TIMEOUT = 10
24442444
# Most MCP services live outside `system.ai`, so this workspace-wide walk needs
24452445
# enough time to enumerate them; a slow workspace still degrades to partial
@@ -2513,30 +2513,30 @@ def _paginated_json_items(
25132513
return items, last_reason
25142514

25152515

2516-
def list_all_mcp_services(
2516+
def walk_catalog_schemas[T](
25172517
workspace: str,
25182518
token: str,
25192519
*,
2520-
deadline_seconds: float = _MCP_SERVICES_WALK_DEADLINE_SECONDS,
2521-
on_progress: Callable[[int, int, int], None] | None = None,
2522-
on_services: Callable[[list[str]], None] | None = None,
2523-
) -> tuple[list[str], str | None]:
2524-
"""Return sorted unique MCP-service full names across every `<catalog>.<schema>`
2525-
in the workspace. The mcp-services API is one-schema-per-call, so this walks
2526-
catalogs -> schemas -> mcp-services in parallel under a wall-clock budget,
2527-
returning partial results once `deadline_seconds` is exceeded.
2528-
2529-
`on_progress`, if given, is called as each schema's listing completes with
2530-
`(schemas_done, schemas_total, services_found)` so callers can render a live
2531-
count. `on_services`, if given, is called with each schema's newly-found service
2532-
names (deduped against everything emitted so far) so callers can stream results
2533-
into a picker as the walk progresses instead of waiting for the full result. Both
2534-
are invoked serially from the draining thread (not the workers).
2535-
2536-
This walk is the slow, workspace-wide counterpart to `list_mcp_services`
2537-
(single schema)."""
2520+
deadline: float,
2521+
probe: Callable[[str, str], T],
2522+
collect: Callable[[T, int, int], None],
2523+
skip_catalogs: frozenset[str] = _UC_FUNCTIONS_SKIP_CATALOGS,
2524+
max_workers: int = _SCHEMA_PROBE_WORKERS,
2525+
) -> str | None:
2526+
"""Discover every user `<catalog>.<schema>` in the workspace and probe each one in parallel.
2527+
2528+
Catalogs and their schemas are listed (skipping `skip_catalogs` and `information_schema`), then
2529+
each schema is probed concurrently until `deadline` (an absolute `time.monotonic()` value)
2530+
passes, so a slow workspace returns partial results instead of hanging. The caller supplies two
2531+
callables and owns whatever they accumulate:
2532+
2533+
- `probe(catalog, schema) -> result`: fetch one schema's data (e.g. its MCP services).
2534+
- `collect(result, done, total)`: handle each probe result as it lands — accumulating,
2535+
de-duping, streaming — where `done`/`total` are the completed and total schema counts.
2536+
2537+
Returns None once the probes run, or a short reason string if there are no catalogs or schemas
2538+
to probe."""
25382539
hostname = workspace_hostname(workspace)
2539-
deadline = time.monotonic() + deadline_seconds
25402540

25412541
catalogs, catalogs_reason = _paginated_json_items(
25422542
f"https://{hostname}/api/2.1/unity-catalog/catalogs",
@@ -2545,23 +2545,20 @@ def list_all_mcp_services(
25452545
timeout=_UC_LIST_HTTP_TIMEOUT,
25462546
)
25472547
if not catalogs:
2548-
return [], catalogs_reason or "no UC catalogs found"
2548+
return catalogs_reason or "no UC catalogs found"
25492549

25502550
catalog_names = [
25512551
c["name"]
25522552
for c in catalogs
2553-
if isinstance(c.get("name"), str)
2554-
and c["name"]
2555-
and c["name"] not in _UC_FUNCTIONS_SKIP_CATALOGS
2553+
if isinstance(c.get("name"), str) and c["name"] and c["name"] not in skip_catalogs
25562554
]
25572555
if not catalog_names:
2558-
return [], "no user UC catalogs found"
2556+
return "no user UC catalogs found"
25592557
if time.monotonic() > deadline:
2560-
return [], "deadline exceeded while listing UC catalogs"
2558+
return "deadline exceeded while listing UC catalogs"
25612559

2562-
# Parallel per-catalog schema listing.
2563-
schema_refs: list[str] = []
2564-
schema_workers = max(1, min(_UC_FUNCTION_PROBE_WORKERS, len(catalog_names)))
2560+
schema_refs: list[tuple[str, str]] = []
2561+
schema_workers = max(1, min(max_workers, len(catalog_names)))
25652562
with ThreadPoolExecutor(max_workers=schema_workers) as pool:
25662563
schema_futures = {
25672564
pool.submit(
@@ -2584,40 +2581,76 @@ def collect_schemas(result, catalog):
25842581
and schema_name
25852582
and schema_name != "information_schema"
25862583
):
2587-
schema_refs.append(f"{catalog}.{schema_name}")
2584+
schema_refs.append((catalog, schema_name))
25882585

25892586
_drain_with_deadline(schema_futures, deadline, collect_schemas)
25902587
pool.shutdown(wait=False, cancel_futures=True)
25912588

25922589
if not schema_refs:
25932590
if time.monotonic() > deadline:
2594-
return [], "deadline exceeded while listing UC schemas"
2595-
return [], "no UC schemas found"
2591+
return "deadline exceeded while listing UC schemas"
2592+
return "no UC schemas found"
25962593

2597-
# Parallel per-schema mcp-services listing.
2598-
names: set[str] = set()
25992594
schemas_total = len(schema_refs)
26002595
schemas_done = 0
2601-
probe_workers = max(1, min(_UC_FUNCTION_PROBE_WORKERS, schemas_total))
2596+
probe_workers = max(1, min(max_workers, schemas_total))
26022597
with ThreadPoolExecutor(max_workers=probe_workers) as pool:
2603-
service_futures = {
2604-
pool.submit(list_mcp_services, workspace, token, ref): ref for ref in schema_refs
2598+
probe_futures = {
2599+
pool.submit(probe, catalog, schema): (catalog, schema)
2600+
for catalog, schema in schema_refs
26052601
}
26062602

2607-
def collect_services(result, _ref):
2603+
def collect_probe(result, _ref):
26082604
nonlocal schemas_done
2609-
found, _ = result
2610-
new = [n for n in found if n not in names]
2611-
names.update(found)
26122605
schemas_done += 1
2613-
if on_progress is not None:
2614-
on_progress(schemas_done, schemas_total, len(names))
2615-
if on_services is not None and new:
2616-
on_services(sorted(new))
2606+
collect(result, schemas_done, schemas_total)
26172607

2618-
_drain_with_deadline(service_futures, deadline, collect_services)
2608+
_drain_with_deadline(probe_futures, deadline, collect_probe)
26192609
pool.shutdown(wait=False, cancel_futures=True)
26202610

2611+
return None
2612+
2613+
2614+
def list_all_mcp_services(
2615+
workspace: str,
2616+
token: str,
2617+
*,
2618+
deadline_seconds: float = _MCP_SERVICES_WALK_DEADLINE_SECONDS,
2619+
on_progress: Callable[[int, int, int], None] | None = None,
2620+
on_services: Callable[[list[str]], None] | None = None,
2621+
) -> tuple[list[str], str | None]:
2622+
"""Return sorted unique MCP-service full names across every `<catalog>.<schema>`
2623+
in the workspace. The mcp-services API is one-schema-per-call, so this walks
2624+
catalogs -> schemas -> mcp-services in parallel under a wall-clock budget,
2625+
returning partial results once `deadline_seconds` is exceeded.
2626+
2627+
`on_progress`, if given, is called as each schema's listing completes with
2628+
`(schemas_done, schemas_total, services_found)` so callers can render a live
2629+
count. `on_services`, if given, is called with each schema's newly-found service
2630+
names (deduped against everything emitted so far) so callers can stream results
2631+
into a picker as the walk progresses instead of waiting for the full result. Both
2632+
are invoked serially from the draining thread (not the workers).
2633+
2634+
This walk is the slow, workspace-wide counterpart to `list_mcp_services`
2635+
(single schema)."""
2636+
deadline = time.monotonic() + deadline_seconds
2637+
names: set[str] = set()
2638+
2639+
def probe(catalog, schema):
2640+
return list_mcp_services(workspace, token, f"{catalog}.{schema}")
2641+
2642+
def collect(result, schemas_done, schemas_total):
2643+
found, _ = result
2644+
new = [n for n in found if n not in names]
2645+
names.update(found)
2646+
if on_progress is not None:
2647+
on_progress(schemas_done, schemas_total, len(names))
2648+
if on_services is not None and new:
2649+
on_services(sorted(new))
2650+
2651+
reason = walk_catalog_schemas(workspace, token, deadline=deadline, probe=probe, collect=collect)
2652+
if reason is not None:
2653+
return [], reason
26212654
if not names:
26222655
if time.monotonic() > deadline:
26232656
return [], "deadline exceeded while listing MCP services"

tests/test_databricks.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json
66
import os
77
import subprocess
8+
import time
89
from decimal import Decimal
910
from urllib.parse import parse_qs
1011

@@ -1220,6 +1221,71 @@ def test_http_404_reason_surfaces_for_invalid_parent(self, monkeypatch):
12201221
assert reason and reason.startswith("HTTP 404")
12211222

12221223

1224+
class TestWalkCatalogSchemas:
1225+
"""The generic catalogs -> schemas -> per-schema probe scaffold, independent of any probe."""
1226+
1227+
def _fake_catalog_http(self, catalogs, schemas_by_catalog):
1228+
def fake_get(url, token, timeout=30):
1229+
if "unity-catalog/catalogs" in url:
1230+
return {"catalogs": [{"name": c} for c in catalogs]}, None
1231+
if "unity-catalog/schemas" in url:
1232+
cat = url.split("catalog_name=")[1].split("&")[0]
1233+
return {"schemas": [{"name": s} for s in schemas_by_catalog.get(cat, [])]}, None
1234+
return None, "unexpected url"
1235+
1236+
return fake_get
1237+
1238+
def test_probes_each_user_schema_and_reports_progress(self, monkeypatch):
1239+
monkeypatch.setattr(
1240+
db_mod,
1241+
"_http_get_json",
1242+
self._fake_catalog_http(
1243+
catalogs=["mycat", "system"],
1244+
schemas_by_catalog={"mycat": ["a", "b", "information_schema"]},
1245+
),
1246+
)
1247+
probed: list[tuple[str, str]] = []
1248+
collected: list[tuple[str, int, int]] = []
1249+
1250+
def probe(catalog, schema):
1251+
probed.append((catalog, schema))
1252+
return f"{catalog}.{schema}"
1253+
1254+
def collect(result, done, total):
1255+
collected.append((result, done, total))
1256+
1257+
reason = db_mod.walk_catalog_schemas(
1258+
WS, "token", deadline=time.monotonic() + 30, probe=probe, collect=collect
1259+
)
1260+
1261+
assert reason is None
1262+
# system is skipped and information_schema is dropped; only user schemas are probed.
1263+
assert sorted(probed) == [("mycat", "a"), ("mycat", "b")]
1264+
assert sorted(r for r, _, _ in collected) == ["mycat.a", "mycat.b"]
1265+
# One collect per probed schema; total is fixed and done climbs to it.
1266+
assert [total for _, _, total in collected] == [2, 2]
1267+
assert sorted(done for _, done, _ in collected) == [1, 2]
1268+
1269+
def test_returns_reason_when_all_catalogs_skipped(self, monkeypatch):
1270+
monkeypatch.setattr(
1271+
db_mod,
1272+
"_http_get_json",
1273+
self._fake_catalog_http(catalogs=["system", "samples"], schemas_by_catalog={}),
1274+
)
1275+
probed: list[tuple[str, str]] = []
1276+
1277+
reason = db_mod.walk_catalog_schemas(
1278+
WS,
1279+
"token",
1280+
deadline=time.monotonic() + 30,
1281+
probe=lambda catalog, schema: probed.append((catalog, schema)),
1282+
collect=lambda *args: None,
1283+
)
1284+
1285+
assert reason == "no user UC catalogs found"
1286+
assert probed == []
1287+
1288+
12231289
class TestListAllMcpServices:
12241290
"""Workspace-wide walk: catalogs -> schemas -> per-schema mcp-services."""
12251291

0 commit comments

Comments
 (0)