Fix pipeline-stuck logical error when a scatter shard finishes early - #113190
Conversation
ScatterByPartitionTransform::prepare treated a finished output port as a reason to keep waiting. In the mid-block distribution branch it required !was_output_processed[i] && canPush(), and canPush() is IS_NEEDED && !HAS_DATA, so a finished port can never satisfy it. That state is legitimate and reachable. An INNER join whose one side is exhausted is done, and IMergingTransformBase::prepare then closes its inputs, which finishes one shard's chain. If a block is at that moment only partially distributed, the finished shard stays unprocessed and unpushable forever while the remaining shards are already processed for that block, so every disjunct is false, prepare returns PortFull forever, nothing is schedulable, and PipelineExecutor::finalizeExecution throws 'Pipeline stuck'. In debug and sanitizer builds that aborts the server. work() already skipped finished outputs. The defect was that prepare() disagreed with it. This adds the missing isFinished() clause and returns Ready when the only unprocessed outputs are finished, mirroring CopyTransform::prepareGenerate, which has the identical fan-out accounting and already handles this correctly. Both parts are required: with the exclusion alone, can_push is still false and prepare still returns PortFull, which was confirmed by building that variant. Fixing the transform covers both producers of these scatters, SortingStep::scatterByPartitionIfNeeded (window PARTITION BY and join sharding) and ShuffleSendStep::updatePipeline. Found by the AST fuzzer, STID 3833-2f20, on four unrelated pull requests. The defect predates the parallel_full_sorting_merge join algorithm: prepare() and work() are byte-identical on a pre-feature commit that hit the same wedge through the window PARTITION BY path. Note the existing "it can deadlock" comment in optimizeJoinByShards.cpp describes two scatters in circular wait, which is a different mechanism from this one, where a single scatter starves on a finished shard. Validated with a deterministic reproducer: 10/10 aborts without the fix, 10/10 passes with it, and the new test aborts on an unpatched binary.
The result query in 04714 joins against an all-NULL key, so its count is 0 whether or not ScatterByPartitionTransform is in the pipeline. If a planner change stops scattering this shape the test keeps passing and silently stops covering the wedge. That is not hypothetical here: optimizeJoinByShards scatters only when both pre-join sorts are Type::Full, and 04500 asserts that a sorted subquery on both sides is not scattered. This query's left side is such a subquery, and it is scattered only because the count() projection plus the NULL key keep applyOrder from producing a FinishSorting. Add an EXPLAIN PIPELINE liveness assertion in the style the family already uses, over the same query text and the same settings as the result query, so it describes the pipeline that query actually runs. The count of 2 is measured, and it is stable across the legacy analyzer, query_plan_join_shard_by_pk_ranges, query_plan_convert_join_to_in, optimize_sorting_by_input_stream_properties and max_threads = 8. The assertion is not itself vacuous: the same EXPLAIN under full_sorting_merge, which must not scatter, counts 0. Also write work and prepare without parentheses in the added comment, per the repo convention for naming a function rather than its application. That part is comment-only and leaves the code token-identical.
Internal second-model review (2 rounds, click to expand)An independent reviewer and a second model reviewed this change before it was opened. Round 1
Verified independently rather than taken from the change's own notes: the reproducer hits the Session id: cron:clickhouse-review-slot-49:20260803-210900 |
Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-impl-slot-47:20260803-193500 |
|
cc @vdimir @novikd, could you review this? |
|
Workflow [PR], commit [8513892] Summary: ❌
AI ReviewSummaryThis PR fixes a real scheduler wedge in Final Verdict✅ No new findings. LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 12/12 (100.00%) · Uncovered code |
The flaky check runs each changed test 50 times in parallel, so the runtime that matters is under contention, not in isolation. All 50 asan_ubsan runs of 04714 landed between 143 s and 187 s and 2 of them crossed the 180 s limit; the debug arm peaked at 166 s, one draw short of the same failure. The left side's row count drove that cost and is not what creates the wedge. Measured on the pristine-master debug binary, the deadlock still reproduces 10/10 at every size from 200000 down to 2000, and 30/30 at 4000, with the same digraph: the scatter sits PortFull with one Finished output and three NeedData ones. 4000 rows also matches the row count the sibling parallel_full_sorting_merge tests already use. Under a 50-way contention proxy pinned to 8 cores, the median run drops from 29.7 s to 8.6 s and the slowest from 31.4 s to 9.7 s. The 50-run randomized harness run drops from 174 s to 82 s. The EXPLAIN PIPELINE liveness assertion still counts 2 scatters at the smaller size, and still counts 0 under full_sorting_merge, so it has not gone vacuous. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR-caused CI fix: 04714 runtime, pushed as 8513892
It was not two unlucky randomizations. All 50 asan_ubsan runs sit in a tight 143-166 s band, and The left side's row count was the cost driver and is not what creates the deadlock. Reduced The test is not weakened. On a binary with only the transform fix reverted, the committed 4000-row I did not use the
Session id: cron:clickhouse-maint-slot-20:20260804-002400 |
CI finish ledger — 8513892Every failure below has an owner: a fixing PR (ours or external), or a full-effort fix task
Neither failure is reachable from this diff, which touches only
Both fixing PRs are open and neither is an ancestor of this branch, so this run predates both fixes. Session id: cron:our-pr-ci-monitor:20260804-070000 |
2741fd7
Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Fixed a
Pipeline stucklogical error in queries that scatter data by partition, such as a window function withPARTITION BYor a join withjoin_algorithm = 'parallel_full_sorting_merge', when one shard's downstream finished while a block was only partially distributed.Description
ScatterByPartitionTransform::preparetreated a finished output port as a reason to keepwaiting. In the mid-block distribution branch it asked
!was_output_processed[i] && canPush(), andcanPushisIS_NEEDED && !HAS_DATA, so a finished port is nevercanPush.That state is legitimate: an INNER join whose one side is exhausted is done, and
IMergingTransformBase::preparethen closes its inputs, finishing one shard's chain. If a blockis at that moment only partially distributed, the finished shard stays unprocessed and unpushable
while the other shards are already processed for that block, so
preparereturnsPortFullforever, nothing is schedulable, and the executor throws at
PipelineExecutor::finalizeExecution.workalready skipped finished outputs. The bug was thatpreparedid not agree with it.This adds the missing
isFinishedclause and returnsReadywhen the only unprocessed outputsare finished, mirroring
CopyTransform::prepareGenerate, which has the identical fan-outaccounting and already handles this. Both parts are needed: with the exclusion alone,
can_pushis still false and
preparestill returnsPortFull. Fixing the transform covers bothproducers,
SortingStep::scatterByPartitionIfNeededandShuffleSendStep::updatePipeline.Found by the AST fuzzer (STID 3833-2f20) on four unrelated PRs, e.g.
AST fuzzer (amd_debug, targeted).
The defect predates
parallel_full_sorting_merge(#109005): the same accounting is presentunchanged in 25.8 and in every 26.x release branch, and a pre-#109005 commit hit the same wedge
through the window
PARTITION BYpath. The existing "it can deadlock" comment inoptimizeJoinByShards.cppdescribes two scatters in circular wait, a different mechanism: herea single scatter starves on one finished shard.
Deterministic repro: 10/10 abort before the fix, 10/10 pass after, and the new test aborts on an
unpatched binary. 50/50 randomized runs green.
Related: #106251 (same wedge class in the sibling
BufferedShardByHashTransform)