A test report, 2026-09-30
A YT Cluster, vanilla PostgreSQL plus an extension, can answer a question written in plain language:
SELECT * FROM yt_llm('How many delivered orders were placed by customers of each country?')
AS t(country text, orders bigint);
You don’t write the query. The node asks a text-to-SQL model to write it, runs it where the data is, and returns the rows. “Where the data is” matters in a cluster. A table may be whole on the node you asked, or it may be sharded so that each node holds only its own rows. It may live in ClickHouse, next to the PostgreSQL cluster. It may also be a table you want computed by DataFusion rather than PostgreSQL.
This report describes a test of exactly that on three YT Cluster nodes and three ClickHouse nodes in docker. It covers ten questions asked on every node, and the bug the test found and that is now fixed. The final run was 101 checks, 101 passed.
yt_llm is a function of the YT Cluster extension. The work happens in ytserv, the
node’s cluster daemon, in five steps.
The node describes what it can read, as three lists. Each table goes on the list of the engine that can answer it exactly:
| list | tables | answered by |
|---|---|---|
sql |
tables this node holds whole | the node’s PostgreSQL |
df |
tables named for DataFusion | DataFusion, over partitions of the rows the node copies out |
ch |
ClickHouse’s own catalog | ClickHouse |
The df list also carries the tables this node does not hold whole, marked
as such. What happens to those is described under shards, below.
One rule runs through all of it: never a partial number. Take a statement over a table no engine here holds whole. It is answered where the whole table is, or it is refused, and the refusal says why. It is never answered from one node’s share of the rows.
YT Cluster can shard a table over its nodes, and it has two kinds of shard.
df list as “sharded”. A question over it is answered from ClickHouse when
ClickHouse has the whole table, and the answer says so. Otherwise it is refused,
because running DataFusion across the shards is not built yet. ┌─────────────── docker network ───────────────┐
│ yt1 yt2 yt3 YT Cluster: PostgreSQL 18, │
│ ytserv, DataFusion 54 │
│ ch1 ch2 ch3 ClickHouse 26.9: 3 shards, │
│ Keeper on all three │
└──────────────────────┬───────────────────────┘
│ https, client certificate
model service (a separate box):
llama.cpp, three models, CPU only
ch there.
So one cluster-wide setting, http://ch:8123, makes every node read its own
ClickHouse. The ClickHouse queries are then spread over the three shards.The models run on a separate host behind a TLS proxy that only accepts clients with a certificate from its own CA:
| job | model |
|---|---|
| pick the tables | Qwen3-4B-Instruct |
| PostgreSQL and DataFusion SQL | XiYanSQL-QwenCoder-7B |
| ClickHouse SQL | Qwen2.5-Coder-7B-Instruct |
All three run on the CPU.
The data was generated deterministically, so every node computes the same rows and a question has one right answer.
| table | where | what for |
|---|---|---|
shop.customers, shop.orders |
whole on every node | PostgreSQL questions |
shop.products, shop.order_items |
whole on every node, named for DataFusion | DataFusion questions |
analytics.page_views |
ClickHouse only, 30 000 rows over three shards | ClickHouse questions |
shop.web_events |
pinned shard (a third per node) and all rows in ClickHouse | fallback to ClickHouse |
shop.clicks |
pinned shard, not in ClickHouse | refusal |
shop.sessions |
ring-hash shard and all rows in ClickHouse | ClickHouse’s own table answers |
shop.refunds |
ring-hash shard, not in ClickHouse | refusal |
Every question was asked on each of the three nodes. An answered question passes when three things hold:
yt_llm() returns the same rows.A refused question passes when the refusal gives the reason it should.
| question | should be answered by | |
|---|---|---|
| S1 | How many customers are there in each country? | PostgreSQL |
| S2 | How many delivered orders were placed by customers of each country? | PostgreSQL (a join) |
| D1 | How many products are there in each category? | DataFusion |
| D2 | What is the total quantity ordered for each product category? | DataFusion (a join) |
| C1 | How many page views came from each country? | ClickHouse, three shards |
| C2 | What is the total view duration in milliseconds for each device type? | ClickHouse |
| R1 | How many web events of each type are there? | ClickHouse, for a pinned shard, saying so |
| R2 | How many times was each button clicked? | refused: pinned shard, not in ClickHouse |
| R3 | How many sessions came from each channel? | ClickHouse’s own table, for a ring-hash shard |
| R4 | What is the total refund amount for each reason? | refused: ring-hash shard, not in ClickHouse |
101 of 101 checks passed.
| part | checks |
|---|---|
| the model service: its three models answer through the proxy, and a connection without a certificate is refused | 4 |
| the cluster: one leader, one cluster id, each node’s ClickHouse file, three shards | 10 |
| the questions: 29 checks on each of the three nodes | 87 |
A few of the statements the models wrote:
-- S2, PostgreSQL
SELECT c.country, COUNT(o.id) AS delivered_orders_count FROM shop.orders o
JOIN shop.customers c ON o.customer_id = c.id WHERE o.status = 'delivered' GROUP BY c.country
-- D2, DataFusion: 2 tables in 5 partitions
SELECT p.category, SUM(oi.quantity) AS total_quantity FROM shop.order_items oi
JOIN shop.products p ON oi.product_id = p.id GROUP BY p.category
-- R1, ClickHouse, with the note "read from ClickHouse, which has every table of the
-- statement: shop.web_events (sharded: each holder has only its own rows) - not whole on this node"
SELECT event_type, COUNT(*) AS event_count FROM shop.web_events GROUP BY event_type
The two refusals said exactly why ([…] marks a cut reference to our design
notes):
Time: 9 to 19 s per question. That is the calls to CPU-bound models, which dominate. Reading the rows afterwards took 0.1 to 0.4 s: the node keeps the statement, so paging through the answer asks no model.
The test was not green on the first try, and one failure was a real bug.
First run: the test was wrong. It used ring-hash shards only, and it failed twice.
So the test now covers both kinds of shard, and R2 asks about buttons, which only one table has.
Second run: the bug. With a pinned shard that ClickHouse also has, R1 failed on every node with “asked again for ch, the model’s statement still reads tables ch does not have”. ClickHouse did have the table, and the statement was right. This is what happened:
The fallback to ClickHouse failed every time it was taken, and no earlier test had taken it.
The fix is a few lines in ytserv’s router. A statement the node had written for ClickHouse now looks its tables up among ClickHouse’s own tables, shadowed ones included, and runs there when ClickHouse has them all. It has a unit test, and it shipped in YT Cluster 0.16.7. The third run passed 101 of 101, and the fix is on our three-node lab cluster.
total_view_duration, total_view_duration_ms and total_duration_ms on
different nodes. The values were identical. A test that compared SQL text or
column names would have failed on correct answers.DataFusion does not yet run over a table that the asked node does not hold whole: one held on another node, or one sharded over several. Until it does, such a question is answered from ClickHouse when ClickHouse has the whole table, and refused otherwise.
The whole test is public: the llm folder of the YT Cluster test repository. Its README explains the setup, the model service included, and results.md is the detailed record of this run.
cp env.example .env # the model service's client certificate, the YT Cluster package
./build.sh # the node image
./up.sh # a new cluster: nodes, data, config, ClickHouse (~2 min)
./test.sh # the questions on all three nodes (~12 min)