Two MCP servers reach the same PyTorch HUD ClickHouse backend — the only ClickHouse you have access to. Pick by task:
pytorch-hud— curated, no-SQL triage and ~145 pre-tuned named queries. Try this first. Cheaper, structured, hard to get wrong.clickhouse-mcp— raw SQL, schema introspection, EXPLAIN, and the tools for debugging and optimizing queries. Drop to this when no curated query fits.
Both hit the same data, so you can prototype with a curated query and refine in raw SQL.
Start here (once per session)
mcp__pytorch-hud__readme_howto_pytorch_treehugging_guide— the triage workflow tour.mcp__clickhouse-mcp__readme_howto_use_clickhouse_tools— the raw-query usage guide.
These MCP tools are deferred: load their schemas with ToolSearch (e.g. query select:mcp__pytorch-hud__readme_howto_pytorch_treehugging_guide) before calling them.
pytorch-hud — curated triage (no SQL)
Each tool answers one question without you writing SQL:
| Tool | Answers |
|---|---|
get_recent_commits_with_jobs_resource |
What's failing on a branch/SHA? The workhorse — filter with job_name_filter_regex, failure_line_filter_regex, and the include_success/pending/failures flags to keep the context small. |
get_job_details_resource |
Full detail for one job, once you've narrowed down. |
get_master_commit_red_resource |
How red/green has trunk been over a time range? |
get_queued_jobs_resource |
Is the CI queue backed up? |
find_commits_with_similar_failures_resource |
When did this failure first appear? (full-text search, date-range bisection) |
get_disabled_test_historical_resource |
Flaky / disabled-test history. |
get_artifacts_resource |
Build/test artifacts for a job. |
Logs — download once, then dissect (never pull a whole log into context):
download_log_to_file_resource → then extract_log_patterns_resource / filter_log_sections_resource / extract_test_results_resource. get_s3_log_url_resource gives the raw URL without downloading.
pytorch-hud — curated named queries (the fast path)
~145 hand-tuned, maintained queries. Prefer these over ad-hoc SQL for speed and correctness.
- List:
get_clickhouse_queries_resource→ catalog of names (e.g.master_commit_red,queue_times_historical,commit_jobs_query,flaky_tests). - Inspect params:
get_clickhouse_query_params_resource(query_name)→{params: {name: Type}, tests: [...]}. Doubles as a data-model hint. - Run:
query_clickhouse_resource(query_name, parameters)—parametersis a JSON string matching those params.
These named queries ARE the repo's clickhouse_queries/. To see exactly what a query does, open torchci/clickhouse_queries/<name>/query.sql and its params.json. The loader is torchci/lib/clickhouse.ts → queryClickhouseSaved(queryName, params). params.json declares params (name → ClickHouse type), optional defaults, and tests (sample values; {"from_now": N} means N days from now — negative is the past, e.g. -7). SQL uses typed placeholders {name: Type} with server-side binding. Editing guide: torchci README.md, section "How to edit ClickHouse queries".
The catalog spans trunk health, commit/job/workflow status, tests (flaky/disabled/timing), queue & latency, cost & duration, runner utilization, merge/revert/PR metrics, autorevert, benchmarks, and CRCR.
clickhouse-mcp — raw SQL, debug & optimize
| Tool | Use |
|---|---|
get_clickhouse_tables |
List tables. database defaults to default; pass databases='all' to span all three. |
get_clickhouse_schema(table) |
Full CREATE TABLE DDL — engine, ORDER BY, skip indexes, column comments. The first stop for any perf work. |
explain_clickhouse_query |
EXPLAIN. Add explain_plan=true (index/granule pruning) and explain_estimate=true (parts/rows/marks to read). |
run_clickhouse_query |
Execute. measure_performance=true → duration_ms + memory_usage. Big results go to a result_file; total_result_rows_n is the true row count. |
get_query_details(name) |
Fetch a checked-in named query to reuse as a template. Pass include_performance_samples=0. |
semantic_search_docs |
Search the official ClickHouse docs (PREWHERE, FINAL, partition pruning, …). |
Optimize a slow query
get_clickhouse_schema(table)— read the DDL. FindORDER BY(the sort key),PARTITION BY, and any skip indexes. This dictates what to filter on.- Filter on the sort-key prefix (or a skip-indexed column). Reuse a proven query via
get_query_detailsif one exists. explain_clickhouse_query(..., explain_plan=true, explain_estimate=true)— confirm pruning before running. In theIndexesblock, granules-kept ≈ granules-total means no pruning, i.e. a full scan.run_clickhouse_query(..., measure_performance=true)— get the realduration_msandmemory_usage.- Iterate: narrow the time range, drop
SELECT *, avoidFINAL, and hit the sort key or a skip index. Re-EXPLAIN → re-run → compare.
Writing performant queries (first try)
The surest path to a fast query is to reuse a curated named query (above) — they're already tuned. When you must write raw SQL, this checklist gets you a near-optimal query on the first pass.
First, read the DDL: get_clickhouse_schema(table). The ORDER BY decides which filters are fast — writing a WHERE without knowing the sort key is guessing.
- Filter on the
ORDER BYprefix, left-to-right — that's what prunes granules. A non-key, non-skip-indexed filter is a full scan. (See Data model essentials for theworkflow_job"sorted byid, not time" trap.) - Never wrap a filter column in a function —
toDate(ts)=…,lower(x)=…defeat the index. Transform the constant instead:WHERE ts >= today() AND ts < today()+1. - Bound time as a range, and select only the columns you need — never
SELECT *(I/O scales with columns read). - Dedup yourself on the SharedReplacingMergeTree tables —
... ORDER BY _inserted_at DESC LIMIT 1 BY id(preferred) orFINAL(costlier); see Data model essentials. A bare SELECT can return duplicates. Check the sort key first: if it ends in a per-row unique column,FINALcollapses nothing andargMaxon one version column drops the remaining tiebreak. - Don't filter on ALIAS columns (e.g.
log_url) — computed per-row, no index. - Reference each CTE once. ClickHouse does not materialize a named subquery — every reference site is a separate execution, so N references cost N scans. Keep
WITH <expr> AS <alias>scalars one level deep too; they expand textually into the AST and nesting them multiplies, up to a hardTOO_BIG_ASTfailure. - Joins: smaller table on the RIGHT; pre-filter each side in a subquery; use
IN (subquery)when you only need to filter.join_use_nulls=0here, so an unmatched row fills with the type default, not NULL —IS NULLwill not find it. - Aggregation: filter in WHERE not HAVING; use approximate
uniqCombined/quantile()on high cardinality;countIf/sumIfto segment in one pass. - Verify, don't assume:
EXPLAIN indexes = 1and readGranules: kept/total— kept ≈ total means a full scan.
For the full ~30-rule reference — mechanisms, examples, join algorithms, memory-spill settings, types, and version notes — read references/clickhouse-perf.md.
Data model essentials
Three databases: default (CI/GitHub — workflow_job, workflow_run, test_run_s3, push, pull_request, merges, …), benchmark (oss_ci_benchmark_*, …), and misc (autorevert_*, disabled_tests_historical, queue_times_*, oss_ci_utilization_*, greenlight_pr_state, …). *_mv are materialized views; *_dict are dictionaries.
- Don't assume "filter on a time column = fast." Check the DDL. Example:
default.workflow_jobisORDER BY (id, run_id, ...)— sorted byid, not by time. AWHERE created_at > ...gets zero primary-key pruning but ~99% pruning from a minmax skip index oncreated_at. If a filter column is neither inORDER BYnor skip-indexed, it is a full scan. - Core tables are SharedReplacingMergeTree (ClickHouse Cloud's ReplacingMergeTree) — rows can be duplicated until a background merge collapses them. Curated queries dedup with
FINALorORDER BY _inserted_at DESC LIMIT 1 BY id.FINALis correct but costs; prefer theLIMIT 1 BYpattern on hot paths. misc.greenlight_pr_state— the GreenLight verdict ledger, one row per emission.ORDER BY (repo, pr_number, run_id, emit_id)ends in a per-row unique column, soFINALcollapses nothing (measured: 904 rows in, 904 out) andargMax(…, version)throws away therun_idtiebreak. Read the current state per PR withORDER BY pr_number, run_id DESC, version DESC LIMIT 1 BY pr_number. This is the general trap, not a quirk of one table — see "Deduplication & FINAL" inreferences/clickhouse-perf.md.default.mergesholds one row per merge push, not per landed PR. A ghstack stack lands as a single push whose non-final commits appear only inpush.commits; those stack members have no row inmergesat all — 8% of pytorch/pytorch main-branch merge commits over a recent 90-day window, and higher inside the GreenLight cohort. If you need every landed PR, derive merges from main-branch commit titles (extract(…, '\\(#(\\d+)\\)\\s*$')) or expand thecommitsarray, rather than readingmerges.- Same shape, same undercount:
clickhouse_queries/reverts/andnum_reverts/read onlypush.head_commit.message. A revert that lands as a non-final commit of a multi-commit push is invisible to them — ~11% of main-branch revert commits over a 90-day window. Neither query warns its readers, so treat their output as a lower bound.
Gotchas (live-verified)
- No trailing
;— the readme says not to append one. The tools happen to tolerate it, but follow the readme to be safe. lint_clickhouse_queryis broken — it returns an internal SQLFluff error on all input. Don't rely on it.get_query_execution_statsis permission-denied for this user (system.query_logis not granted). The same denial hitsget_query_detailsperformance samples, so always passinclude_performance_samples=0. That leavesexplain+run(measure_performance=true)as your only perf instruments.run_clickhouse_queryinline output caps atinline_result_limit_bytes(default 1024, max 10240). Cap withLIMITand read theresult_filefor large results.