Repository navigation
hotblocks: one heavy query holds the whole query pool, and new tasks queue behind it #118
Description
Problem
Every query step runs as a task on sqd_polars::POOL (QuerySlot::run in crates/hotblocks/src/query/executor.rs). Inside the task, the legacy engine and the storage reader split their work with par_iter and rayon::join (crates/query/src/plan/plan.rs, crates/storage/src/table/read/reader.rs). These sub-tasks run on the same pool.
A free rayon worker looks for work in this order (find_work in rayon-core):
- its own deque;
- jobs stolen from other workers;
- only then the global injector, where
POOL.spawnputs new tasks.
So while one heavy query has parallel parts left, all workers take those parts, and new tasks wait in the injector. One query holds the whole pool for the length of its scan, and many light queries queue behind it.
What the #114 histograms and a per-thread sample show
- The queue wait has a long tail: the median is far below 1 ms, the p99 is hundreds of ms. Less than 10% of the tasks carry about 90% of all the waiting time.
- When nothing is queued, the pool is mostly idle.
- With a long queue (30+ tasks), about one query task runs, while nearly all pool threads are on the CPU. The sample read
hotblocks_query_tasks_queuedand the state of eachpolars-thread from/proc/<pid>/task/*/stat.
More threads (--query-threads) make the wait shorter but do not remove it: a heavy scan spreads over all threads, whatever their number.
Plan
1. Find the heavy queries and why they are heavy. #117 adds hotblocks_query_slow_tasks_total{dataset_name, client_id, step} and a rate-limited WARN line with the query for each task that holds a worker longer than --slow-task-threshold-ms. After it ships, group the lines by query shape and client, and look at the chunks, blocks and buffered bytes of each.
2. A scheduling fix behind a flag, the default unchanged. Options:
- A. Thread groups. Split the pool into groups of K threads, and send a new task to the least loaded group. A heavy query takes at most K threads. Limitation: the legacy engine does some work in polars (joins in
crates/query/src/plan/rel.rs), and polars uses the globalPOOLdirectly, so that part still spreads over the whole pool. - B. No parallelism inside one query. This removes the blocking fully, but heavy queries get the largest slowdown.
- C. Fix it only in the new engine (Concurrent queries: some wait in the shared rayon pool while others keep finishing sqd-query-engine#57) and get the fix with the migration.
The cost: the parallel part of a heavy query gets K threads instead of N, so it is at most N/K times slower. Light queries, the majority, lose most of their queue wait. Measure with an A/B on one replica: the queue wait p99 and mean, the run time p99, and hotblocks_stream_duration_seconds per dataset and client.
3. The new engine. Bring the same limit into subsquid/sqd-query-engine#57, so that the migration does not bring the problem back.
Done when
- The heavy queries are known and explained.
- A flag-gated fix lowers the queue-wait tail in an A/B, and its cost for heavy queries is measured.
- sqd-query-engine#57 has the same limit, or the issue records why it does not need one.
Activity
mo4islona commentedon Oct 16, 2026
Update: offline reproduction, a fix behind a flag, and what makes a task heavy
Offline reproduction
A synthetic bench reproduces the problem and compares scheduling options. It models the query pool only:
POOL.spawnfrom tokio does.par_iterover scans,par_iterover columns, arayon::jointree over pages, with a sequential part around it.The load is set so that the queue wait of an 8-thread pool matches production, with and without heavy tasks:
A second effect: nested tasks. The queue wait is not the whole cost. A worker that waits in
joinruns other jobs (wait_until_cold→find_workin rayon-core), and these include whole query tasks from the injector, heavy ones too. The waiting task ends only after the task it picked up. In the bench this makes the light-task latency (spawn to end) 3–4 times its queue wait. #117 shows this effect in production asnested_ms.Results with heavy tasks, 120 s per run, the same seed for every policy. Times in ms; "own" is the run time without nested tasks.
With no heavy tasks, the shared pool gives a light latency of 13.9 / 249 / 1175 ms and thread per task gives 10.2 / 184 / 602 ms.
par_iterandjoininside a task then run on the task's own thread, with no change to the engine code. Light tasks get the same latency as with no heavy tasks at all. Heavy tasks need 3.6× more own time, but their mean latency grows only 1.7× and their max falls, because in the shared pool they also wait for the tasks nested in them.POOL, so the polars part of a query stays parallel and shared. With a third of the heavy parallel work in polars, light tasks keep almost all of the gain. With two thirds, part of the tail comes back, but it stays far below today.Limits: the shape of the heavy tasks is a guess; a step that becomes several times slower can reach a client's read timeout; each run has only 25–38 heavy tasks, and a second seed for the shared pool gave 33.5 / 712 / 1649 ms, so the tails carry noise of about 2×.
The fix, behind a flag
--query-thread-per-task, also switchable at runtime withPATCH /settings {"query": {"threadPerTask": true}}, off by default. The gaugehotblocks_query_thread_per_taskshows the mode of each replica. A PR follows. The rollout: switch one replica, watch the tail ofhotblocks_query_task_run_secondsand client retries, then switch the others.What makes one task heavy
The number of sub-tasks is not the problem: every query splits the same way, light ones into dozens of jobs too. The problem is the total work in a parallel phase. While it has jobs left, free workers take them before any new task.
From production metrics:
From the code, the cost of one chunk:
execute_scansreads the predicate columns for every row of the table in the chunk. ThefromBlock..toBlockrange is applied only inexecute_output. After compaction a chunk holds up to 200,000 rows per table, so a query that needs one block of a large chunk pays for the whole chunk.Two hypotheses, not verified yet:
fromBlockfalls into large compacted chunks, so that every step scans whole chunks.If (2) holds, a separate and cheap fix is to apply the block range in the scans. If the rows of a table in a chunk are sorted by block number, the range is one contiguous run of rows. This needs a check of how the storage reader applies predicates.
#117 names the queries: it logs every task that holds a worker for more than
--slow-task-threshold-ms, with the query, the chunks and blocks it read, and the bytes it buffered.Next
mo4islona commentedon Oct 16, 2026
Update: thread per task in production
One replica ran with a thread per task (switched with
PATCH /settings), and the other stayed on the shared pool as the control, for about an hour of a slow period. The switched replica carried about 1.4 times the tasks of the control.join. Per task, runs over 1.6 s were as frequent as on the control, and runs over 3.3 s were about 9 times less frequent.too many tasksor worker panics on either replica.Both replicas now run with a thread per task.
What the slow-task log (#117) shows
Next