Repository navigation
Check cancellation while searching a constant array #120870
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
groeneai
wants to merge
6
commits into
ClickHouse:master
Choose a base branch
from
groeneai:has-const-array-cancellation
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
4bea950
Check cancellation while searching a constant array
groeneai 846d178
Wait on evidence, not on elapsed time, in 05227's kill scenario
groeneai 881eb13
Give 05227's deadline scenarios a window they can measure
groeneai c631758
Keep dbms self-contained after adding a cancellation check to arrayIn…
groeneai c687601
Merge branch 'master' into has-const-array-cancellation
alexey-milovidov 113a176
Merge branch 'master' into has-const-array-cancellation
alexey-milovidov File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
7 changes: 7 additions & 0 deletions
7
tests/queries/0_stateless/05227_has_const_array_cancellation.reference
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| active parts: 1 | ||
| read-time route: stopped in function has | ||
| read-time route spent over 1s filtering marks: 1 | ||
| planning route: stopped in function has | ||
| planning route spent over 1s filtering marks: 1 | ||
| KILL QUERY: cancelled | ||
| KILL QUERY reached the index scan: 1 |
158 changes: 158 additions & 0 deletions
158
tests/queries/0_stateless/05227_has_const_array_cancellation.sh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| #!/usr/bin/env bash | ||
|
|
||
| CURDIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) | ||
| # shellcheck source=../shell_config.sh | ||
| . "$CURDIR"/../shell_config.sh | ||
|
|
||
| # A `set` skip index evaluates its condition as an ExpressionActions over the values the index stored, so a | ||
| # `has()` over a constant array scans that array once per stored value inside one function call. That loop had | ||
| # no cancellation checkpoint, so neither `max_execution_time` nor `KILL QUERY` could stop the query while it | ||
| # ran; the reported query kept working for 182 seconds after its cancellation flag was already set. | ||
| # | ||
| # `timeout_overflow_mode = 'break'` is what makes the deadline oracle exact instead of a timing threshold: in | ||
| # break mode `QueryStatus::checkTimeLimit()` returns false rather than throwing, and every pre-existing call | ||
| # site in the index path discards that bool, so the checkpoint added here is the only code that can raise an | ||
| # error, and its message names the function. Without it the query raises nothing at all and returns a count. | ||
| # | ||
| # Exactly one active part is required, not merely tidy: cancellation is already checked once per (part, index) | ||
| # before the work, so with two or more parts those checks interrupt the query on their own and the test would | ||
| # pass without the fix. | ||
| $CLICKHOUSE_CLIENT -q " | ||
| CREATE TABLE t_set_idx | ||
| ( | ||
| type UInt32, | ||
| uid LowCardinality(String), | ||
| INDEX idx_uid uid TYPE set(10000) GRANULARITY 1 | ||
| ) | ||
| ENGINE = MergeTree | ||
| ORDER BY type | ||
| -- pinned in the DDL: a granularity above the set's 10000 capacity would store far fewer values than | ||
| -- there are rows, and it is one stored value per row that makes the scan long | ||
| SETTINGS index_granularity = 1024; | ||
|
|
||
| INSERT INTO t_set_idx SELECT 100500, toString(number % 10000) FROM numbers(200000); | ||
| OPTIMIZE TABLE t_set_idx FINAL; | ||
| " | ||
|
|
||
| echo "active parts: $($CLICKHOUSE_CLIENT -q " | ||
| SELECT count() FROM system.parts WHERE database = currentDatabase() AND table = 't_set_idx' AND active")" | ||
|
|
||
| # 50000 constant needles, none of them a uid value, so every stored value is compared against all of them: | ||
| # ~1e10 comparisons, about a minute even on a release build, so a 3s deadline always lands inside has(). | ||
| NEEDLES="arrayMap(x -> toString(x), range(1000000, 1050000))" | ||
|
|
||
| # $1 = use_skip_indexes_on_data_read (1 = evaluated while reading, 0 = evaluated during planning; the two | ||
| # routes resolve the query differently, so both are checked), $2 = extra settings appended to the SETTINGS | ||
| # clause verbatim, leading comma included, $3 = the deadline in seconds, and also the attempt's id so that the | ||
| # probe below cannot read an earlier attempt's row. Reports through $deadline_outcome and $burned_in_scan. | ||
| deadline_attempt() { | ||
| local qid="${CLICKHOUSE_DATABASE}_deadline_$1_$3" | ||
| if timeout 60 $CLICKHOUSE_CLIENT --query_id "$qid" --query " | ||
| SELECT count() FROM t_set_idx WHERE has($NEEDLES, uid) | ||
| SETTINGS use_skip_indexes = 1, -- the defect is in skip index condition evaluation | ||
| use_skip_indexes_on_data_read = $1, | ||
| optimize_rewrite_has_to_in = 0, -- keep the linear has(), do not rewrite it to a Set | ||
| max_execution_time = $3, timeout_overflow_mode = 'break'$2" 2>&1 \ | ||
| | grep -q "elapsed time limit reached in function has" | ||
| then | ||
| deadline_outcome="stopped in function has" | ||
| else | ||
| deadline_outcome="NOT stopped" | ||
| fi | ||
|
|
||
| $CLICKHOUSE_CLIENT -q "SYSTEM FLUSH LOGS query_log" | ||
| # The time has to have been burned in skip index filtering rather than in the data path, otherwise the | ||
| # test would keep passing while covering something else entirely. | ||
| burned_in_scan=$($CLICKHOUSE_CLIENT -q " | ||
| SELECT max(ProfileEvents['FilteringMarksWithSecondaryKeysMicroseconds']) > 1000000 | ||
| FROM system.query_log | ||
| WHERE current_database = currentDatabase() AND query_id = '$qid' AND type != 'QueryStart'") | ||
| } | ||
|
|
||
| # The deadline is what stops the query, so the scan only gets whatever is left of the deadline once the phases | ||
| # ahead of it have taken their share, and that share is whatever the runner charges for them: the guard above | ||
| # is really the predicate "the work before the scan fitted in 2 of the 3 seconds". So an attempt the guard | ||
| # rejects is discarded and retried with a deadline large enough to dwarf that cost. Only the window is | ||
| # retried, never the oracle: a deadline that fails to stop the query is reported from the first attempt. | ||
| # $1 = use_skip_indexes_on_data_read, $2 = label, $3 = extra settings | ||
| deadline() { | ||
| local seconds | ||
| for seconds in 3 12; do | ||
| deadline_attempt "$1" "$3" "$seconds" | ||
| [ "$deadline_outcome" = "NOT stopped" ] && break | ||
| [ "$burned_in_scan" = "1" ] && break | ||
| done | ||
|
|
||
| echo "$2: $deadline_outcome" | ||
| echo "$2 spent over 1s filtering marks: $burned_in_scan" | ||
| } | ||
|
|
||
| # Bulk filtering evaluates a whole part in one condition call, so this scenario needs the periodic checkpoint | ||
| # and cannot be satisfied by the entry one; left unpinned, the randomizer picks the per-granule arm, where a | ||
| # later granule's entry check would already raise the error, on about half of the runs. | ||
| deadline 1 "read-time route" ", secondary_indices_enable_bulk_filtering = 1" | ||
| # Deliberately free in that dimension, which is what keeps the per-granule arm covered. | ||
| deadline 0 "planning route" "" | ||
|
|
||
| # The reported symptom: KILL QUERY does not stop it. No deadline here, so only the kill can end the query. | ||
| # | ||
| # $1 = seconds the query must already have been running before the kill is sent, and also the attempt's id, so | ||
| # that the probe below cannot read an earlier attempt's row. Reports through $kill_outcome and $reached_scan. | ||
| kill_attempt() { | ||
| local qid="${CLICKHOUSE_DATABASE}_kill_$1" | ||
| local out="${CLICKHOUSE_TMP}/killed_query_$1.out" | ||
|
|
||
| $CLICKHOUSE_CLIENT --query_id "$qid" --query " | ||
| SELECT count() FROM t_set_idx WHERE has($NEEDLES, uid) | ||
| -- Unlike the deadline scenarios above, this one's oracle is a bound, so its cost must not depend on | ||
| -- settings randomization: without bulk filtering the same scan takes 14.5s instead of 70s unfixed, | ||
| -- and the query condition cache would serve it the verdict the two queries above already computed. | ||
| SETTINGS use_skip_indexes = 1, use_skip_indexes_on_data_read = 1, | ||
| secondary_indices_enable_bulk_filtering = 1, use_query_condition_cache = 0, | ||
| optimize_rewrite_has_to_in = 0" > "$out" 2>&1 & | ||
| local query_pid=$! | ||
|
|
||
| for _ in {1..150}; do | ||
| [ "$($CLICKHOUSE_CLIENT -q " | ||
| SELECT count() FROM system.processes WHERE query_id = '$qid' AND elapsed > $1")" = "1" ] \ | ||
| && break | ||
| sleep 0.2 | ||
| done | ||
|
|
||
| if timeout 15 $CLICKHOUSE_CLIENT -q "KILL QUERY WHERE query_id = '$qid' SYNC" > /dev/null 2>&1; then | ||
| wait "$query_pid" | ||
| if grep -q "QUERY_WAS_CANCELLED" "$out"; then | ||
| kill_outcome="cancelled" | ||
| else | ||
| kill_outcome="finished without being cancelled" | ||
| fi | ||
| else | ||
| wait "$query_pid" | ||
| kill_outcome="still waiting after 15s" | ||
| fi | ||
| rm -f "$out" | ||
|
|
||
| $CLICKHOUSE_CLIENT -q "SYSTEM FLUSH LOGS query_log" | ||
| # A liveness guard, not an oracle: it reads 1 whether or not the fix is present, and its job is to reject | ||
| # an attempt in which the kill ended the query before it entered index filtering, where the line below | ||
| # would still say "cancelled". The pre-existing per-index cancellation check returns before the timer | ||
| # starts, so zero here means the scan was never entered. | ||
| reached_scan=$($CLICKHOUSE_CLIENT -q " | ||
| SELECT max(ProfileEvents['FilteringMarksWithSecondaryKeysMicroseconds']) > 100000 | ||
| FROM system.query_log | ||
| WHERE current_database = currentDatabase() AND query_id = '$qid' AND type != 'QueryStart'") | ||
| } | ||
|
|
||
| # The process list entry is inserted before planning starts, so elapsed time only approximates "the scan is | ||
| # running": on a loaded runner the phases ahead of the scan can outlast a one-second wait, and the kill then | ||
| # ends the query in a window where none of the work has run. So an attempt that the guard rejects is discarded | ||
| # and retried with a longer wait rather than asserted on, each retry allowing four times as long. Only the | ||
| # landing window is retried, never the oracle: a kill that does not stop the query reports "still waiting" | ||
| # from any attempt, and the guard holds there because the whole scan then runs. | ||
| for wait_before_kill in 1 4 16; do | ||
| kill_attempt "$wait_before_kill" | ||
| [ "$reached_scan" = "1" ] && break | ||
| done | ||
|
|
||
| echo "KILL QUERY: $kill_outcome" | ||
| echo "KILL QUERY reached the index scan: $reached_scan" |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This closes the
ColumnConst(Array)carrier, buthasstill has a second constant-container path throughMap.executeMap()doesconvertToFullColumnIfConst()and then hands a plainColumnArraytoexecuteArrayImpl()(src/Functions/array/arrayIndex.h:943-968), sohas(const_map, key_column)never reaches thisCancellationBudget. That leaves the samerows * map_size()unchecked loop the PR is fixing for arrays, just on the documentedhas(map, key)surface;notHasinherits it because it delegates tohas.I think
executeMap()needs to preserve const-ness here the same wayMapToSubcolumnAdapteralready does insrc/Functions/array/FunctionsMapMiscellaneous.cpp:255-256, so a const map becomes a const keys array and reusesexecuteConst(). A focused regression test onhas(const_map, column)would pin the remaining carrier.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed, measured on the pushed head. With this PR's own fixture (200k rows, one part) and
max_execution_time = 1, timeout_overflow_mode = 'break', a 1000-key constant map runs 2009 ms with noerror, while the array carrier stops at 1004 ms with
Code 159 ... elapsed time limit reached in function has.KILL QUERYsent after 1s returns 1.27s later against 0.166s for the array, and the map error carries nowhile executing 'FUNCTION has(...)', so the cancellation is seen by the next pipeline check, not inside thefunction.
notHasinherits it.mapContains(map, key)andhas(mapKeys(map), key)already preserve constnessand already stop, so the gap is specific to the
has(map, key)spelling.The materialized copy makes work track memory, about 1.66 GiB per second of unchecked scan (1000 keys: 1910 ms
at 3.17 GiB; 10000 keys dies on a single 13.04 GiB allocation, so the huge-map example errors rather than
stalls).
max_memory_usagedefaults to 0, so the window is bounded by server memory rather than by a constant.I prototyped your remedy: it stops at the deadline, uses 6.05 MiB instead of 3.17 GiB, is 1.4x faster, and a new
scenario in the test reddens on this head for exactly that line. It is not free, though. 31 of 32 differential
cases are byte-identical; the 32nd is
has(map(NULL::Dynamic, 1), NULL), which goes 0 to 1. That is whatmapContains,mapKeysand both array paths already return, but it is pinned at 0 by04338_has_map_dynamic_key_lowcardinality_arg, and the non-const map path keeps returning 0, so const-preservation trades an array-vs-map inconsistency for a const-vs-non-const one. A constant map above 1e6 keys
also starts raising
Code 128 TOO_LARGE_ARRAY_SIZE, asmapContainsalready does there.A Dynamic/NULL semantics change does not belong in a cancellation fix for the constant-array carrier this issue
reports, so I am not folding it in here. I will send it separately, in the shape with no semantic delta (chunk
the row range inside
executeMapand check cancellation between chunks, which also bounds the peak memoryabove), unless a maintainer prefers the const-preserving version with the NULL answer changed deliberately.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sent as #121034, and not in the shape I said I would use. The
Dynamic-NULL delta that made me pick chunking does not exist in the const-preserving version as implemented:04338_has_map_dynamic_key_lowcardinality_argstays green with its reference untouched, and 92 differential cases across every needle wrapper and key type are byte identical tomaster. Chunking measured worse, since it bounds the copy instead of removing it, so #121034 removes the copy and puts the cancellation checkpoint in the loop that performs the work.