Skip to content

Observe query cancellation in the compact-part mark read loop - #113423

Open
groeneai wants to merge 10 commits into
ClickHouse:masterfrom
groeneai:groeneai/fix-cancellation-compact-reader-mark-loop
Open

Observe query cancellation in the compact-part mark read loop#113423
groeneai wants to merge 10 commits into
ClickHouse:masterfrom
groeneai:groeneai/fix-cancellation-compact-reader-mark-loop

Conversation

@groeneai

@groeneai groeneai commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Changelog category (leave one):

  • Bug Fix (user-visible misbehavior in an official stable release)

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Fix KILL QUERY, and max_execution_time under the default timeout_overflow_mode = 'throw', being ignored while a query reads a Compact MergeTree part with a small index_granularity. A single block read walked every mark without checking cancellation, so one call could run for minutes past the limit.

Description

MergeTreeReaderCompactSingleBuffer::readRows walks one mark per iteration and had no
cancellation check. Limits are otherwise enforced only between blocks
(MergeTreeSelectProcessor::read, PipelineExecutor::checkTimeLimit), so with
index_granularity = 1 one block spans as many marks as max_block_size allows and no limit
applies until it returns.

Measured on a 400k-mark Compact part: a 1 s max_execution_time reported 17.9 s elapsed, and
KILL QUERY ... SYNC took 17-18 s; both drop to the limit with this change. The overshoot
scales linearly with max_block_size (6.77x at 1e8, 1.43x at 300k, 1.07x at 65536), which is the
per-block interrupt granularity. The CI symptom is a Stress hung check: a cancelled
INSERT ... SELECT ran 1592 s with is_cancelled = 1 after writing 10 rows (25 rows / 24 PRs /
1 master over 14 d on Stress test (arm_asan_ubsan) alone).

The catch guard is required, not incidental. readRows is a function-try-block whose catch calls
reportBroken() for anything isRetryableException does not recognise, and it lists neither
QUERY_WAS_CANCELLED nor TIMEOUT_EXCEEDED - so the check alone reports a
healthy active part broken (on ReplicatedMergeTree, an observed part check). The guard
is local to this catch; isRetryableException is untouched, because its 25 call sites drive fetch
retries where a cancellation is not retryable in any sense.

MergeTreeReaderTextIndex::readRows has the same loop and is fixed too, on either part type; it
needs no guard, reporting no broken part. The Wide main reader needs no check
(MergeTreeReaderWide::readRows loops per column, not per mark). Hot-path cost is within noise.

Scope: KILL QUERY is honoured in every overflow mode, but a plain max_execution_time expiry
under timeout_overflow_mode = 'break' still overshoots, because checkTimeLimit returns false
there instead of throwing. Making it stop would turn a documented silent stop
(02896_max_execution_time_with_break_overflow_mode) into a client-visible error, so it is left
out.

Part of the class tracked by #107474.

groeneai and others added 4 commits August 4, 2026 21:35
MergeTreeReaderCompactSingleBuffer::readRows walks one mark per iteration and had
no cancellation check, while query limits are otherwise only enforced between
blocks (MergeTreeSelectProcessor::read's `while (!is_cancelled)` and
PipelineExecutor::checkTimeLimit). With index_granularity = 1 a single block
spans as many marks as max_block_size allows, so one call runs unbounded and
neither max_execution_time nor KILL QUERY takes effect until it returns.

Measured on a 400k-mark compact part: a 1 s max_execution_time reported 17.9 s
elapsed and a KILL QUERY ... SYNC took 17-18 s, both dropping to the limit with
this change. Overshoot scales linearly with max_block_size (6.77x at 1e8 rows,
1.43x at 300k, 1.07x at 65536), which is the per-block interrupt granularity.
The CI symptom is a Stress hung check: a cancelled INSERT ... SELECT on
03710_pr_join_with_mv ran 1592 s with is_cancelled = 1 after writing 10 rows,
surviving the 90 s hung-check drain window (25 rows / 24 PRs / 1 master over
14 d on Stress test (arm_asan_ubsan) alone).

readRows is a function-try-block whose catch calls reportBroken() for anything
isRetryableException does not recognise, and that predicate lists neither
QUERY_WAS_CANCELLED nor TIMEOUT_EXCEEDED. Without the second half of this change
a cancelled query marks a healthy active part broken: on ReplicatedMergeTree it
enqueues a part check (observed, then "looks good"). The guard is local to this
one catch; isRetryableException itself is left alone because its 25 call sites
drive fetch retries and part-check scheduling, where a query cancellation is not
retryable in any sense.

Wide parts do not carry this defect: MergeTreeReaderWide::readRows loops per
column, not per mark, and the same fixture shows no overshoot (1000.1 ms against
a 1000 ms limit). Merges and mutations are unaffected, having no query context,
so tryGetQueryContext() returns null and no check is performed - verified by
running OPTIMIZE FINAL and an ALTER UPDATE under a 1 s limit and observing both
complete. Hot-path cost on an uncancelled 400k-mark read is within noise
(median 18.18 s vs 18.34 s, n=5 each).

Cancellation is observed for String, Nullable, LowCardinality,
LowCardinality(Nullable), Array, Map, Tuple and Decimal columns.
The comment named max_execution_time, max_rows_to_read and max_block_size as
the settings the tags protect against. Measured against tests/clickhouse-test:
max_execution_time and timeout_overflow_mode do not appear at all, and
max_rows_to_read appears once as a fixed override for infrastructure queries,
not as a randomized setting. What actually makes the assertions stop
discriminating is the read-speed family, whose fast combinations let the
unpatched read finish inside the asserted bounds.

Also record that no-random-merge-tree-settings is redundant today, since
no-random-settings already disables the merge-tree randomizer, so a reader
does not conclude the tag is load-bearing on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MergeTreeReaderTextIndex::readRows has the same unchecked per-mark loop the
previous commit fixed in the compact reader, and it is a separate carrier: when
a query's only predicate is served by the text index it requests no physical
column, so MergeTreeReadTask::initializeReadersChain leaves the main reader out
of the chain entirely and the compact reader's check never runs.

Measured on a 400k-mark Compact part with a text index and 30 hasAnyTokens
conjuncts: a 1 s max_execution_time reported 2.5-3.8 s elapsed before this
change and stops at the limit after it. Reverting only this check while keeping
the compact one leaves the new test arm red, and reverting only the compact
check leaves it green, so each site is independently load-bearing.

No isQueryCancellation guard is added here: unlike the compact reader this
function is not a function-try-block and never calls reportBroken, so a
cancellation exception cannot be mistaken for a part-health signal.

The new test arm pins use_skip_indexes_on_data_read = 0 and
query_plan_direct_read_from_text_index = 1. Both are randomized, and each one
on its own disarms the assertion: with skip-index-on-data-read enabled a
MergeTreeReaderIndex takes the chain-front slot, so the text reader is driven
by continueReadingChain and the interrupt-site diagnostic is never appended;
with direct read disabled the text reader is not used at all and the
diagnostic comes from the compact reader's own check, so the arm would pass
with this change reverted.

Also corrects two comment claims from the previous commit. The interrupt-site
token is appended by MergeTreeReadersChain::read, not by readRows, whose own
catch appends a differently worded message; and checkTimeLimit is not "cheap (a
flag plus an elapsed-timer read)" because throwProperExceptionIfNeeded takes
cancel_mutex unconditionally. The call itself is unchanged: the lock-free
variant, checkTimeLimitSoft, returns bool and never throws, so it cannot
interrupt the walk, and gating on is_killed first would drop the
max_execution_time half of the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The arm added for MergeTreeReaderTextIndex checks that the query stopped inside
a block read, and its neighbour checks that the text index pruned granules, but
neither shows that the reader under test executed. EXPLAIN indexes = 1 renders
index_stats, which filterPartsByPrimaryKeyAndSkipIndexes populates whether or
not the direct-read rewrite ran; that rewrite is decided separately, in
processAndOptimizeTextIndexFunctions. So the assertion written to prove the
reader served the query could not detect a silent decline into a plan that never
enters it, which is exactly what happens at
query_plan_direct_read_from_text_index = 0, where the arm passes with the fix
reverted. The setting is pinned per query, so the gap was covered, but only by
prevention and not by detection.

Give the timeout query its own query_id and read
ProfileEvents['TextIndexReaderTotalMicroseconds'] back from system.query_log.
That event has one increment site in the tree, the first statement of
MergeTreeReaderTextIndex::readRows, and ProfileEventTimeIncrement's destructor
reports during stack unwinding, so it records even though the query leaves that
function by throwing. The row is matched on type = 'ExceptionWhileProcessing':
the fixed server ends this query with a thrown TIMEOUT_EXCEEDED and writes no
QueryFinish row. log_profile_events defaults to true and the runner does not
randomize it, so no further pin is owed.

On one binary the new line reads 1 with direct read enabled and 0 with it
disabled, while the pruning line reads 1 in both, which is what distinguishes an
execution oracle from a second pruning assertion. The other text index profile
events cannot serve here: TextIndexReadPostings,
TextIndexReadGranulesMicroseconds and TextIndexUseHint all sit on the
granule-load pruning path and would report work in precisely the declined
configuration.

Reverting only the text index check still reddens one line, the interrupt-site
assertion, and leaves the new line at 1, since that reader does run and simply
does not stop. The pruning assertion, both pins and every earlier reference line
are unchanged. 50 randomized runs pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@groeneai

groeneai commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author
Internal second-model review (5 rounds)

An independent reviewer read the resulting code cold, without the author's evidence, and a
second model re-reviewed each revision. Findings and verdicts across all rounds:

The text-index reader carries the same defect (MergeTreeReaderTextIndex::readRows).
Its per-mark loop was unchecked, it is driven by the same block-sized row count, and the
compact reader is dropped from the readers chain entirely when the query needs no physical
column, so the new check did not run at all on that shape. Fixed by adding the same check;
that reader does not report parts broken, so it needs no exception guard. Proven independent
by disjoint mutants: reverting only the text check reddens only the text arm.

The text arm's assertion could not detect the decline it was written to catch.
EXPLAIN indexes = 1 renders index_stats, populated by granule pruning independently of
whether the direct-read rewrite ran, so it proved the index pruned, not that the reader under
test executed. Fixed by adding an execution oracle keyed on
ProfileEvents['TextIndexReaderTotalMicroseconds'] for the query's own query_id - one
increment site in the tree, the first statement of the function under test, reported during
unwinding so it records even though the query leaves by throwing. Measured 1 with direct read
enabled and 0 with it disabled on one binary, while the pruning line reads 1 in both. Three
other text-index events offered as equivalents were refused as vacuous: all sit on the
granule-load pruning path and would read non-zero in exactly the declined configuration.

A kill assertion bounded by timeout 30 passed on its own mutant (the unpatched kill
returns in 17-18 s). Replaced with a timing-independent interrupt-site token; caught only
because the mutation arm was actually executed.

The part-check oracle was unscoped, counting rows left by earlier runs of the same
test, so pristine HEAD reported part checks 2. Now scoped by currentDatabase().

The test's oracle comment named the wrong emitter. While reading part comes from
MergeTreeReadersChain::read, not readRows. The oracle is sound (that catch wraps only the
in-block read); the comment was corrected and the assertion left unchanged.

The per-mark check was described as "a flag plus an elapsed-timer read". It is not:
checkTimeLimit takes a mutex per call. The call is still correct (only it throws, and the
hot-path cost is within noise), so the inaccurate justification was removed rather than
restated. Two alternatives were declined with evidence: checkTimeLimitSoft never throws and
would silently reinstate the bug, and pre-testing is_killed would drop the
max_execution_time half.

A carrier claim in the description was broader than the diff supports. It read as if
Wide parts were defect-free in general; the text-index reader is part-type agnostic and
Wide + text is an ordinary supported shape. Now scoped to the main reader, which is the
true claim: MergeTreeReaderWide::readRows loops per column, not per mark.

⚠️ Comment style. Function names in comments follow the repository convention of f
rather than f().

💡 Findings raised and refuted before being filed, recorded rather than acted on: a
cancellation exception carrying an unrelated error code past the new guard (unreachable - the
only producers are BACKUP/RESTORE coordination, and backup hard-links part files instead
of reading them); an IO-layer TIMEOUT_EXCEEDED masking a genuinely broken part (no such
throw on this read path); and a suggestion to widen isRetryableException itself (declined:
its 25 call sites drive fetch retries and part-check scheduling, where a query cancellation
is not retryable in any sense).

💡 One nit is recorded and deliberately not changed: the execution oracle uses LIMIT 1
rather than an aggregate, so a zero-row result would omit the line rather than print 0. The
row cannot be absent - TCPHandler enqueues the query_log element before sending the
exception, and SYSTEM FLUSH LOGS waits for the last index - and either failure mode is an
equally loud red.

Also verified independently of the author's evidence: exactly two readers loop per mark and
both are fixed; MergeTreeReaderIndex has no mark loop; the init() catch runs before the
check and cannot see the new throw; and MergeTreeSequentialSource's reportBroken catch is
unreachable from this change, because that source requests one mark's rows per call, so the
loop body runs at most once and a loop-top check cannot fire after any progress.

@groeneai

groeneai commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author
Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes, on demand, no sanitizer and no randomization needed. One Compact part with index_granularity = 1 and 400k marks; SELECT count(), sum(length(...)) with max_block_size = 1e8, preferred_block_size_bytes = 0, max_execution_time = 1, timeout_overflow_mode = 'throw', max_threads = 1. Part type asserted Compact before every measurement, since on a Wide part the same fixture exercises a different code path and measures nothing.
b Root cause explained? MergeTreeReaderCompactSingleBuffer::readRows loops one mark per iteration with no cancellation check, while limits are enforced only between blocks (MergeTreeSelectProcessor::read's while (!is_cancelled), PipelineExecutor::checkTimeLimit). index_granularity = 1 plus a large max_block_size makes one block span every mark, so the flag set at t=0 is not read again until that single call returns. Confirmed by a discriminator: the overshoot scales linearly with max_block_size on a fixed part (6.77x at 1e8, 1.43x at 300k, 1.07x at 65536), which is the per-block interrupt granularity and no other layer's.
c Fix matches root cause? Yes: checkTimeLimit() at the top of that loop, with the lookup hoisted out. Rejected as band-aids: capping max_block_size when index_granularity is small (shrinks the uninterruptible unit without bounding it, and changes read performance for every query), and pinning the affected test's settings (hides a user-facing bug behind a tag).
d Test intent preserved / new tests added? New test 04746_compact_part_read_cancellation.sh (+ reference). No existing test weakened; 03710_pr_join_with_mv, the CI victim, is deliberately untouched. The test asserts the interrupt SITE (While reading part, appended by MergeTreeReadersChain::read's catch, which wraps only the in-block startReadingChain call) rather than a wall-clock threshold, plus part health. A third arm covers the text index reader; it pins use_skip_indexes_on_data_read = 0 and query_plan_direct_read_from_text_index = 1, because each of those randomized settings on its own makes that assertion pass with the fix reverted. That arm also carries an EXECUTION oracle, keyed on ProfileEvents['TextIndexReaderTotalMicroseconds'] in system.query_log for its own query_id. Its neighbour EXPLAIN indexes = 1 assertion proves only that the index PRUNED granules (index_stats is populated by filterPartsByPrimaryKeyAndSkipIndexes independently of whether the direct-read rewrite ran), so it could not detect a silent decline into a plan that never enters MergeTreeReaderTextIndex::readRows. That event has exactly one increment site in the tree, the first statement of the function under test, and its scope guard reports during stack unwinding, so it records even though this query leaves by throwing; the row is matched on type = 'ExceptionWhileProcessing', since the fixed server ends the query with a thrown TIMEOUT_EXCEEDED and so writes no QueryFinish row. Measured on one binary, the new line reads 1 with direct read enabled and 0 with it disabled, while the pruning line reads 1 in both. The three other text-index profile events were rejected as vacuous for this purpose: all of them sit on the granule-load pruning path and would read non-zero in exactly the declined configuration the oracle exists to catch.
e Both directions demonstrated? Yes, on Build-ID-verified binaries. Reported elapsed against a 1 s limit: base 6753/6685 ms, fixed 1032/1046 ms. KILL QUERY ... SYNC on a 400k-mark part: 17-18 s without the check (3 runs), 0.60 s with it. One mutant per guard site was built and run: removing only the checkTimeLimit() call reddens 3 reference lines; removing only the catch guard reddens exactly 1 (part checks), disjoint from the first. An earlier draft bounded the kill with timeout 30, which the first mutant PASSED (its kill returns in 17-18 s); that vacuous assertion was replaced, and it was caught only because the mutation arm was actually executed. The text index arm was measured the same way on three Build-ID-asserted binaries: fixed stops at 1000 ms with the interrupt-site diagnostic present in 10 of 10 runs, while pristine HEAD and a mutant with only the new check reverted overshoot to 2.5-3.8 s with the diagnostic absent in 5 of 5 each. A wall-clock bound is deliberately NOT asserted for that arm: the unpatched text read is only 3.7-5.0 s, below the threshold the other arms use, so it would pass on both mutants and assert nothing, and enlarging the work to widen the margin makes the diagnostic flaky on the fixed binary. Rebuilding after the mutants returned the Build ID to exactly the reviewed value.
f Fix is general across code paths? A literal git grep "while (read_rows < max_rows_to_read)" returns one hit, but that pattern is too narrow: a looser while.*read_rows finds 2 of 7 MergeTreeReader*.cpp files, the second being MergeTreeReaderTextIndex::readRows, whose loop carries an extra && from_mark < total_marks condition. It is a genuine second carrier (a query served only by the text index requests no physical column, so MergeTreeReadTask.cpp:301 omits the main reader from the chain and the compact check never runs) and is fixed in the same PR, with no catch guard because that function is not a function-try-block and never calls reportBroken (grep: 0). Proven independent by disjoint mutants: reverting only the text check reddens only the text arm, reverting only the compact check reddens only the three compact arms. MergeTreeReaderIndex does not loop over marks. Wide parts were assessed by measurement and deliberately NOT patched: MergeTreeReaderWide::readRows loops per column (bounded), and an equivalent Wide fixture honoured the limit at 1000.095 ms against 1000 ms versus 6768 ms for Compact. Merges and mutations carry no query context, so no check runs; verified behaviourally by running OPTIMIZE FINAL and an ALTER UPDATE under a 1 s limit and observing both complete. init()'s catch cannot see the new throw (it runs before the check).
g Fix generalizes across inputs (params/datatypes/wrappers)? Yes, executed matrix on one Compact part: String, Nullable(String), LowCardinality(String), LowCardinality(Nullable(String)), Array(String), Map(String,String), Tuple(String,UInt64), Decimal(18,4) all interrupt inside readRows (8/8). The change sits in the mark loop, above serialization, so there is no type predicate that could strip only one wrapper.
h Backward compatible? (maintainer-approved exception only) Yes. No new setting, no default change, no serialization or format change, so no SettingsChangesHistory.cpp entry and no experimental gate. The only behaviour change is that a query already cancelled, or already past max_execution_time in throw mode, now throws inside the read instead of after it, which is what both mechanisms are for.
i Invariants and contracts preserved? The part-health invariant is the one at risk and is why the catch guard ships with the check: readRows's catch calls reportBroken() for anything isRetryableException does not recognise, and that predicate lists neither QUERY_WAS_CANCELLED nor TIMEOUT_EXCEEDED, so the check alone would mark a healthy active part broken. Proven with its own mutant (an enqueued part check on ReplicatedMergeTree, which then reports "looks good"). isRetryableException itself is untouched, because its 25 call sites drive fetch retries and part-check scheduling where a query cancellation is not retryable. The throw happens at the top of the iteration, before any per-granule state is cleared and before any column is appended, so no partially-filled column is published, next_mark is not advanced, and the reader is discarded with the failed task. The same holds for the text index reader: the check is the first statement of its loop body, before from_mark, from_row, read_rows and fallback_offset advance, and current_mark/current_row are still written only on the normal exit path. It holds no lock and reports no broken part, so no health-signal contract is involved there.

Session id: cron:clickhouse-impl-slot-6:20260804-202100

@groeneai

groeneai commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

cc @Ergus @serxa, could you review this? A single readRows call walks every mark of the requested block, so with index_granularity = 1 and a large max_block_size neither KILL QUERY nor max_execution_time is observed until the call returns; this adds checkTimeLimit at the top of that loop in the compact and text-index readers, plus a guard so the resulting cancellation is not mistaken for a broken part.

@PedroTadim PedroTadim added the can be tested Allows running workflows for external contributors label Aug 5, 2026
@clickhouse-gh

clickhouse-gh Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [3125620]

Summary:

job_name test_name status info comment
Finish Workflow FAIL
python3 ./ci/jobs/scripts/workflow_hooks/new_tests_check.py FAIL
Build (amd_binary) ERROR
Build (arm_asan_ubsan) ERROR
Build (arm_binary) ERROR
Build (llvm_coverage_build) ERROR
Stateless tests (arm_asan_ubsan, targeted) ERROR
Stateless tests (amd_msan, flaky check) ERROR
Stateless tests (amd_asan_ubsan, db disk, distributed plan, sequential, 1/3) ERROR
Stateless tests (amd_tsan, parallel) ERROR
Stateless tests (arm_binary, parallel) ERROR

AI Review

Summary

This PR adds per-mark checkTimeLimit checks to MergeTreeReaderCompactSingleBuffer and MergeTreeReaderTextIndex, adds the matching reportBroken() exemption for query-cancellation in the compact reader and MergeTreeSequentialSource, and pins the compact/text-index behavior with 04746_compact_part_read_cancellation.sh. The compact/text-index fix itself looks correct in the current head and CI is green, but one previously raised object-storage cancellation path remains unresolved.

Findings

⚠️ Majors

  • [src/Storages/MergeTree/MergeTreeReaderWide.cpp:75-76] [dismissed by author -- https://github.com/Observe query cancellation in the compact-part mark read loop #113423#discussion_r3727594845] S3-backed reads can still turn query cancellation into reportBroken(). src/IO/S3/Client.cpp:422 and :711 rewrite a failed request to the query's cancellation via CurrentThread::checkIfNotCancelled(), but the Wide reader catches at MergeTreeReaderWide.cpp:75-76, 106-107, 256-257 and the compact init() catch at MergeTreeReaderCompactSingleBuffer.cpp:162-163 still classify that exception as non-retryable. A killed or timed-out query can therefore enqueue a bogus part check for a healthy remote part. Suggested fix: extend those catches to exempt isQueryCancellation(std::current_exception()) too, or centralize the health-signal classifier for every reportBroken() site that can see query-context cancellations.
Final Verdict

The per-mark cancellation fix and its new stateless coverage look good, but I would not treat the review as fully clean while the existing S3-backed reader path above still misreports healthy parts as broken.

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Aug 5, 2026
The Style check's catch_all rule flags a terminal catch (...) that neither
logs, rethrows nor saves the exception. Here the swallow is the correct
answer rather than an oversight: isQueryCancellation is a pure classifier
that rethrows the stored exception_ptr only to read its error code, and all
81 throw sites of the three codes it tests (QUERY_WAS_CANCELLED,
QUERY_WAS_CANCELLED_BY_CLIENT, TIMEOUT_EXCEEDED) raise a plain DB::Exception,
so anything reaching the catch-all is by construction not a cancellation and
false is the complete answer. Nothing is lost: the caller still rethrows for
diagnostics and the exception continues to propagate.

Annotated with an Ok comment, which is the form the rule documents
(ci/jobs/check_style.py:512-516 accepts a comment matching \bok\b in the
block or the two preceding lines) and the form the sibling predicate called
on the same line already uses -- isRetryableException in checkDataPart.cpp
has the identical rethrow-and-classify shape and passes the check only
because its terminal catch carries "But it is OK". 84 catch (...) sites in
src/ use this annotation. Restructuring was considered and rejected: routing
through getExceptionErrorCode would replace a code test with an equivalent
one while adding an indirection, and that helper carries the very same
Ok annotation on its own catch-all.

Comment-only: the preprocessed code is byte-identical to the reviewed tree,
so no rebuild and no re-verification of the fix is owed.
The test cancelled reads with max_execution_time and asserted the reported
elapsed stayed under a fixed bound. That limit runs from the start of the
query, so loading 400001 marks and analysing the text index are charged
against it, and several limit checks fire before the read begins. On a
loaded runner that work alone outlasts the limit: the deadline fires while
the query is still being prepared, the read is never entered, and the
interrupt-site diagnostic cannot appear. All 36 failing runs across 8
checks show it. In the text arm the query_log row was ExceptionBeforeStart,
so the execution oracle matched no row and its line went missing entirely.

Reproduced by shortening the limit below the preparation cost: at 0.5 s and
lower the text arm fails exactly as CI did, with
TextIndexReaderTotalMicroseconds = 0. No constant fixes this, because one
number must exceed preparation (2 ms to 15 s, load dependent) and stay
under the total read.

The compact arm now raises the limit until the deadline lands inside the
read, keyed on the interrupt-site diagnostic itself rather than on a wall
clock, since that diagnostic is the only signal reporting where the query
stopped. A limit that stops timing out is already too generous, so the
ladder ends there.

The text arm cancels with KILL QUERY after a handshake that waits for every
mark to be selected while no row has been delivered, which is the pattern
the neighbouring compact arm already used: that arm's line matched in all
36 failing runs. Its oracle also gains read_rows, which an uninterrupted
walk sets to 400000. The walk costs about 150 ms per predicate, and at 30
predicates it lasted 4.5 s, short enough that the cancel could arrive after
it finished; 120 predicates put it at 18 s, matching the compact arm.

Verified on the reviewed binary, whose build id is unchanged because no
source is touched: 25 of 25 randomized runs, and 12 of 12 at six-way
self-contention on a host at load 81, which inflates preparation as CI
does. Both mutation arms still redden, on disjoint lines: reverting the
compact check fails only the two compact lines, reverting the text-index
check only the two text lines. Both settings the text arm pins were
re-measured under the new mechanism and both remain load-bearing.
Comment thread src/Storages/MergeTree/MergeTreeReaderCompactSingleBuffer.cpp
…catch

MergeTreeSequentialSource::generate reports a suspected broken part for every
non-retryable exception, and isRetryableException does not recognise the three
cancellation codes. Once the compact reader's mark loop can throw them, a
cancelled foreground query enqueues a part check for a healthy Compact part.

MergeTreeDataPartCompact::getReader returns MergeTreeReaderCompactSingleBuffer,
so a Compact read through this source enters the loop this PR changed. The
foreground caller is WhatIfEmpiricalEstimator, which drives the source from
EXPLAIN WHATIF on the calling thread with the query's process-list element
attached and no experimental setting gating it; the other three callers are
merges and mutations. reportBroken reaches broken_part_callback, which for
ReplicatedMergeTree queues the part for a full data check.

Measured on a Replicated Compact table with index_granularity = 1 and 400k
marks. With both sites instrumented, a deadline landing inside the read
reached the catch twice and ran reportBroken both times, and the server then
logged a full re-check of the active part ending "Part all_0_0_0 looks good."
Reverting only the new conjunct restores that: on a paired ladder where each
arm reached the read once, the pre-fix arm enqueued one check and the fixed arm
enqueued none, with the part active and not detached in both.

The classifier moves from the compact reader's anonymous namespace into
checkDataPart, beside isRetryableException, and both catch sites call it, so it
is defined once rather than twice. Both files already included that header for
isRetryableException, so this adds no dependency. Adding the codes to
isRetryableException itself would have been wrong: its three call sites inside
checkDataPart treat retryable as "return empty checksums and retry later".

A loop-top deadline check in this source was measured dead in an earlier round
and stays out: readRows is called with one mark's rows, so its loop body runs
at most once. That is also why the defect is narrow, since a deadline usually
fires between marks. Reachability of a regression arm for the wiring measured
1 in 20, so no arm is added rather than a flaky one; the classifier itself is
covered by the existing arms of 04746.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread tests/queries/0_stateless/04746_compact_part_read_cancellation.sh Outdated
The test crossed the runner's 600 s per-test wall on two sanitizer arms
(`amd_tsan, flaky check` 5/5 at 600.06-600.19 s, `amd_msan, WasmEdge, parallel,
2/2` 1 of 5 at 600.18 s, the other 4 passing at 249.8-448.2 s).

The runner prints the processes left in the test's process group on a timeout,
which names the phase each failure was in: five were in the text arm's
`EXPLAIN indexes = 1` assertion and one was in that arm's `INSERT`. None was in
either cancellation read, so the reads under test are not what has to shrink.

Analysing the predicate costs about as much per term as reading does, so the
120-term conjunction made that assertion the most expensive statement in the
file: 3.0 s on a debug build and 12.3 s under ASAN. The assertion only asks
whether the text index is usable for this predicate shape, which does not depend
on how many terms are conjoined, so it now asks it of a single term. Measured on
a debug build, it answers 1 at one term and at 120, and answers 0 at both counts
with `use_skip_indexes = 0`, with `ignore_data_skipping_indices = 'idx_s'`, and
for a predicate the index cannot serve. Under ASAN it drops to 0.57 s.

Both tables shrink from 400k to 100k rows, and the text predicate count rises
from 120 to 240 to hold the read that the cancel has to land inside at about
8.9 s, against a floor of roughly 70 ms for the earliest kill the handshake can
deliver. The insert that the sixth failure was in falls from 5.1 s to 1.7 s
under ASAN.

Every assertion and oracle is retained. Reverting both `checkTimeLimit` guards at
the new size still reddens all three interrupt-site lines, so the smaller
workload continues to detect the defect. The test now takes 7.5 s where it took
13.0 s, and 50 randomized runs pass with per-run times of 5.49 to 6.14 s.
@clickhouse-gh

clickhouse-gh Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

No arm_release build profile data for commit af9b00b - the build was skipped, reused from cache, or predates profile upload.

The text arm sent one KILL QUERY as soon as ProfileEvents['SelectedMarks'] was
non-zero and read_rows was still 0, then asserted the interrupt happened inside
MergeTreeReaderTextIndex::readRows. That handshake cannot establish what it was
read as establishing. SelectedMarks is incremented in
ReadFromMergeTree::initializePipeline, once index analysis has finished but
before the pipeline runs, and no ProfileEvent advances while the reader walks
marks: measured over 95 samples spanning a whole 8.9 s walk, the only counters
that moved were produced by the polling client itself, and
TextIndexReaderTotalMicroseconds appears only as its scope guard reports during
unwinding. So the two signals available sit on either side of the read, never
inside it, and a cancel sent on the earlier one lands before the reader is
entered whenever the interval between them stretches.

The compact arm above does not have this problem for a reason that is easy to
miss: its handshake reads CompressedReadBufferBlocks > 1000, which is
incremented from readHeaderAndGetCodec, so it can only accrue while data is
actually being decompressed.

Observed on Stateless tests (amd_debug, flaky check), where one of five runs
printed 'text kill observed not interrupted inside the part read' together with
'text index reader ran 0 0', that second line being the reader reporting it was
never entered. The four other runs passed, so nothing about the fix under test
changed; only where the cancel happened to land did.

Retry instead, keyed on the same interrupt-site diagnostic the compact arms
already key on, growing the pause before the cancel. Each pause stays below how
long the read lasts: a pause that outlasted it would let the query finish and
leave the diagnostic absent for the opposite reason, which this retry cannot
distinguish and would answer by pausing longer still.

Verified by forcing the failure, since a runner cannot produce it on demand.
Killing at the first instant the query is visible guarantees the cancel precedes
the reader; the retry then converges in 4 of 4 trials, while the single cancel it
replaces fails in 4 of 4. Reverting the reader's check still reddens exactly the
two text lines, so the arm keeps discriminating. On the shipped tree the test
passes 50 of 50 randomized runs and 12 of 12 six-way concurrent runs on a host at
load 63, worst case 7.8 s against the runner's 600 s limit.

/// A cancelled or timed-out query says nothing about the part's health, so such an exception must
/// not reach reportBroken.
bool isQueryCancellation(std::exception_ptr exception_ptr);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isQueryCancellation is being introduced as the shared way to keep query cancellation from reaching reportBroken(), but the sibling reader catches that already see cancellation from lower I/O are still unchanged. Object-storage reads rewrite failed requests to the query's cancellation via CurrentThread::checkIfNotCancelled() in src/IO/S3/Client.cpp:422 and :711; that exception will still be treated as non-retryable in MergeTreeReaderWide.cpp:75-76, 106-107, 256-257, and MergeTreeReaderCompactSingleBuffer.cpp:162-163.

So killing or timing out a query while reading an S3-backed part can still enqueue a bogus part check for a healthy part (ReplicatedMergeTree will verify it again). Please extend those catches to exempt isQueryCancellation(std::current_exception()) as well, or the new helper/comment is only true for the two call sites touched here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both halves check out, and they point in opposite directions, so I have taken the second one and declined the first.

The sibling catches are a different carrier, and this branch does not arm them. Every site you cite is verbatim correct at origin/master, and the mechanism is real: checkIfNotCancelled reaches throwProperExceptionIfNeeded, which throws TIMEOUT_EXCEEDED or QUERY_WAS_CANCELLED; isRetryableException lists neither, so such an exception is classified non-retryable and reaches reportBroken(). What decides ownership is whether this diff makes that reachable, and it does not:

  • src/IO/S3/Client.cpp (md5 3ee869320a496f4d93fb8f093c87f61e) and MergeTreeReaderWide.cpp (md5 68bb5fd7a6e4586fe55f58509745161d) are byte-identical across the merge-base 91687a7d610e, this head, and origin/master.
  • The S3 route predates the branch: 538f478f2929678 introduced it, and git merge-base --is-ancestor 538f478f2929678 91687a7d610e returns 0.
  • Neither new throw site can unwind into a frame you name. They are at MergeTreeReaderCompactSingleBuffer.cpp:43 and MergeTreeReaderTextIndex.cpp:496 (zero in MergeTreeReaderWide.cpp). MergeTreeReadersChain drives range_readers in a sequential loop, not nested, so reader types cannot mix within one part. And the CompactSingleBuffer catch you cite is init()'s, which returns at :20 before the mark loop holding the new check at :38-43, i.e. strictly upstream of it.

So widening the exemption into those four catches would be a second carrier in this PR, and it collides with my open #112918, which already edits MergeTreeReaderWide.cpp:256 and this header (git merge-tree against it: 4 conflicted paths). I would rather not ship four more exemptions with no test pinning them.

The comment did overclaim, and that is fixed in 3125620. /// ... such an exception must not reach reportBroken reads as a property of the codebase, while the helper is applied at 2 of the 6 reportBroken() catch sites in src/; the PR description already scoped it correctly ("the guard is local to this catch") and the header contradicted it. It now describes only what the predicate detects:

/// True for a cancelled or timed-out query, which says nothing about the part's health.

One correction to your citation for whoever reads this later: MergeTreeReaderCompactSingleBuffer.cpp:162-163 resolves only on this branch. On origin/master that file is 152 lines and the init() catch is at :147-148; the other four line references are exact on master.

The pre-existing S3-cancellation path is worth someone fixing, and I have not filed it: a spurious enqueuePartForCheck on a healthy Active part is re-verified as healthy, so it costs background work and log noise rather than correctness, and I have no S3 fixture to demonstrate it with.

@groeneai

groeneai commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

CI finish ledger — af9b00b

Every failure below has an owner: a fixing PR (mine or external), or a full-effort fix task
whose fixing-PR link will be posted here when it opens. Only CH Inc sync is exempt.

175 check-runs, all completed: 158 success / 17 skipped / 0 failure. Config Workflow and
Finish Workflow both succeeded, and 130 of the 131 substantive jobs ran, so nothing was dropped.
The owner table is empty.

Check / test Reason Owner / fixing PR
(no failures)

CI is green, but the change request above on checkDataPart.h is not addressed yet, so this is not
ready for a human review pass. I am picking that up next rather than treating the green run as final.

Session id: cron:our-pr-ci-monitor:20260806-133000

The declaration comment asserted that a cancellation "must not reach
reportBroken", which reads as a codebase-wide invariant. The helper is
applied at 2 of the 6 reportBroken() catch sites in src/, so the wider
claim is not delivered by this change; the PR description already scopes
it correctly ("the guard is local to this catch").

Reported by clickhouse-gh[bot] on checkDataPart.h:22. The sibling catches
it names (MergeTreeReaderWide.cpp:76,107,257 and
MergeTreeReaderCompactSingleBuffer.cpp init()'s catch) are a different
carrier: object-storage I/O rewriting a failed request to the query's
cancellation via CurrentThread::checkIfNotCancelled (S3/Client.cpp:422,
:711). That route predates this branch -- both files are byte-identical
across the merge-base, this head and origin/master, and 538f478
which introduced it is an ancestor of the merge-base -- and none of the
new throw sites can unwind into those frames, so widening it here would
be a second, unpinned concern.

Comment-only: the header is code-identical after stripping comments.
@groeneai

groeneai commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

CI finish ledger - 3125620

Every failure below has an owner: a fixing PR (mine or external), or a full-effort fix task
whose fixing-PR link will be posted here when it opens. Only CH Inc sync is exempt.

Check / test Reason Owner / fixing PR
Build (arm_binary), Build (arm_asan_ubsan), Build (llvm_coverage_build), Unit tests (asan_ubsan), Unit tests (msan), Unit tests (msan, function_prop_fuzzer), Stateless tests (amd_tsan, parallel), Stateless tests (arm_asan_ubsan, targeted), Stateless tests (amd_msan, flaky check), Stateless tests (amd_asan_ubsan, db disk, distributed plan, sequential, 1/3) runner infrastructure, not a test or compile result: every one of these failed on GitHub's own Set up job step, before checkout and before any ClickHouse code ran. Durations are 4-5 minutes for jobs that normally take tens of minutes, and CIDB holds zero failing test rows for any of them a fix task is moved to pending (investigating at full effort - fixing-PR link to follow on this PR)
Stateless tests (arm_binary, parallel) and (arm_binary, sequential) cascade of the above: FileNotFoundError: Clickhouse binary not found from functional_tests.py:624, because Build (arm_binary) never produced an artifact. CIDB records both as check_status = error with 0 test rows same owner as the row above
Integration tests (amd_asan_ubsan, db disk, old analyzer, 1/6) job-level, 0 failing test rows in CIDB on this sha; the sibling 3/6 shard on the same head is green (765 OK / 0 FAIL) fixing PR #112984 (mine, open)
Finish Workflow / Post Hooks not a test result: new_tests_check.py fails because all four per-arch Bugfix validation jobs are DROPPED/SKIPPED, so nothing was measured. Cascade of the dropped pipeline above same owner as the first row

None of these is attributable to this PR: no compile ever started on the failing jobs, and the only
CIDB failure row on this head is the Post Hooks bookkeeping line. The 24 jobs that did run are green.

Session id: cron:our-pr-ci-monitor:20260806-213000

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

can be tested Allows running workflows for external contributors pr-bugfix Pull request with bugfix, not backported by default

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants