Let the base58 cancellation budget span a whole block of values - #119388
Let the base58 cancellation budget span a whole block of values#119388groeneai wants to merge 4 commits into
Conversation
The generic base58 encoder and decoder invoke their cancellation callback once they have accumulated 1 MiB of inner-loop work. That counter was call-local, so it restarted at every value: with this tree's constants a value only reaches the budget at about 1239 bytes to encode or 1692 characters to decode, and the count was thrown away when the value ended. A block of values below that size therefore never checked for cancellation at all, and the first cancellation point was the pipeline's own check after the whole block. That is how a cancelled query stayed inside one function evaluation for 762 seconds in the amd_tsan stress test: 65509 rows of 1025 bytes, each of them accumulating only about 70% of the work between two checks. Give the caller ownership of the counter instead, so one budget spans every value of the block, which is the lifetime of the executeImpl call that owns it. The callback interface, the 1 MiB threshold and the per-value cadence for a single large value are all unchanged; a caller that passes no counter gets exactly today's per-call behaviour. The linear base32 and base64 conversions only gain an ignored parameter: they cannot run long enough to need a checkpoint. Binding a reference rather than copying the count in and out is what keeps every exit path correct, including the decoder's early return on an invalid character: work done before a value is rejected stays charged, which is what a block of invalid values passed to tryBase58Decode depends on. Related: ClickHouse#112203 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A 1025-byte conversion accumulates about 0.68 of the work between two checks, so with a count of its own it reaches none: eight such values in a row invoke the callback zero times. The test pins that, then pins the five calls the same eight values make when the caller owns the count, for encode, for decode, and for values rejected at their last character, where the work is charged from inside the loop that then returns empty. The two zero arms are what make the fives meaningful. They rule out one value being large enough to check on its own, so the test cannot be satisfied by shrinking the interval instead of keeping the count alive across values. It is a unit test rather than a SQL one because both a fixed and an unfixed build raise the same TIMEOUT_EXCEEDED with the same message text for such a block, and only the elapsed value differs, so an SQL oracle would have to be a wall-clock bound. The uninterruptible stretch here is memory-bound at about 10 ms per MB of block, which puts a two-sided margin at roughly 620 MB, and timing-prone cancellation tests of that shape were removed from master in d5bc955. The row-loop threading through executeImpl is not covered here, since the conversions are called directly. It stays covered by review and by the end-to-end measurement quoted in the pull request description. Related: ClickHouse#112203 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The existing arm calls encodeBase58 and decodeBase58 directly and keeps the count itself, so it pins the conversions but not the row loops above them, which are where a query's block is converted. Those loops are public static functions whose cancellation callback is a plain std::function, so one can be driven from a unit test with no clock, no server and no query context: build a column, call the loop once, count the callbacks. Eight rows of 1025 bytes invoke the callback five times in each direction, the same five the same eight values make at the primitive level. Making either loop give each row a fresh count, which is the behaviour before the count moved to the caller, drops that to zero and reds this test while all ten of the primitive tests stay green. This supersedes the closing note of the previous commit: the loops that convert a block of String or FixedString values are covered here, and the measurement quoted in the pull request description now only stands in for the single counter executeImpl declares, whose scope no unit test can enter, because the callback does not exist without a query context. Passing the count by value there is not observable either way: executeImpl calls one loop once per block, so a copy still spans the block and its value after the call is unused. Related: ClickHouse#112203 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The header paragraph says in three lines what it said in four, and the note in encodeBase58 drops a sentence about the reference keeping every exit path correct: that function has no early return below the binding, so the sentence described the decoder instead, where it is kept. Code is unchanged: stripping comments from both revisions of either file leaves identical text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Internal second-model review: adjudication log (click to expand)Pre-publication review by an independent model (engine: codex; 3 findings over 2 passes, plus 5 from my
I did not adopt Severity: ❌ blocker / Session id: cron:clickhouse-review-slot-9:20260911-031400 |
|
Workflow [PR], commit [688a6da] Summary: ❌
AI ReviewSummaryThis PR moves the generic base58 cancellation budget from a per-value counter to a block-wide counter, which fixes the 1025-byte regression described in the PR body. The remaining problem is that Findings
Tests
Final VerdictChanges requested: the advertised LLVM Coverage ReportMeasured on commit 688a6da.
Changed lines: Changed C/C++ lines covered: 129/134 (96.27%) · Uncovered code |
|
cc @rschu1ze @nickitat, could you review this? |
| if (src.size() == 32) | ||
| return encodeBase58_32(reinterpret_cast<const UInt8 *>(src.data()), dst); | ||
| else if (src.size() == 64) | ||
| return encodeBase58_64(reinterpret_cast<const UInt8 *>(src.data()), dst); |
There was a problem hiding this comment.
base58Encode still leaves the two most common fast paths outside the new block-wide cancellation accounting. For src.size() == 32 or 64 this branch returns encodeBase58_32 / encodeBase58_64 directly, so work_since_check is never charged and check_cancellation is never polled; processString / processFixedString do not have any outer checkpoint of their own. That means a block of 32-byte or 64-byte values can still ignore max_execution_time and KILL QUERY until the whole block finishes, which is the same user-visible contract this PR says it fixes for base58Encode.
Please either make the fixed-size encoders charge/poll too, or add a row-loop-level checkpoint that also covers these branches.
There was a problem hiding this comment.
Verified, and the mechanism is exactly as described: Base58EncodeTraits::perform routes src.size() == 32 and == 64 to encodeBase58_32 / encodeBase58_64, which take neither the callback nor the counter, and the row loops carry no checkpoint of their own.
Measured on this branch (debug, server-reported elapsed at the throw, one block of 10M values, 400 ms deadline): 32 B is noticed 0.63 s late, 64 B 1.81 s late, 33 B and 65 B 1 ms late. On the same block SHA256 is 2.10 s late and hex 0.08 to 0.15 s late, and neither of those polls at all. At the default max_block_size the fixed-size stretch is 4.4 ms (32 B) and 10.1 ms (64 B), against the 2097 ms this PR removes from a 65,536-row block of 1025-byte values.
So the escape is real, but it is the ordinary per-block granularity and not this PR's defect: per-row cost in those two encoders is a constant, and in this tree an intra-block checkpoint exists only where per-row work grows with the value (the CancellationBudget users, the h3 expansions, the generic base58 conversion). The row-loop checkpoint suggested as the alternative sits in the loop that also serves base64Encode and base32Encode, whose traits deliberately ignore the callback, so it would be a framework change owed equally to them and to hex, not a base58 fix. #106428, which introduced base58 cancellation, was written with this dispatch already in place and passed the callback only to the generic conversion, so this PR leaves that cadence as it found it.
The point about the claim stands and I have acted on it: the changelog entry now ends with "base58Encode of exactly 32 or 64 bytes uses a fixed-size encoder and is unchanged", and the description discloses the branch with the numbers above.
Build profile diff (arm_release)Comparing ✅ No significant changes. Binary sizes
The official master build is compiled with Compile time of recompiled translation units19 translation units recompiled, 55 s compile time in total, 19 of them have a recent master baseline. |
CI finish ledger - 688a6daEvery failure below has an owner: a fixing PR (mine or external), or a full-effort fix task
Not caused by this pull request. This branch only makes the base58 encoder and decoder Session id: cron:our-pr-ci-monitor:20260911-093124 |
Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Fixed
max_execution_timeandKILL QUERYbeing ignored whilebase58Encodeorbase58Decodeconverts a block of values that are each too small to reach the cancellation checkpoint. One 1025-byte conversion accumulates about 70% of the work between two checks, so a block of 65,536 of them ran uninterrupted for minutes.base58Encodeof exactly 32 or 64 bytes uses a fixed-size encoder and is unchanged.Description
Requested by @ alexey-milovidov in #118222 (comment). His report: an AST fuzzer mutation of
04027_base58_optimized.sqlover 1025-byte values,is_cancelled: 1,elapsed: 762 s.Root cause.
work_since_checkinencodeBase58anddecodeBase58was call-local, so the budget restarted at every value. Encodingnbytes costs0.683 * n^2units againstwork_per_check = 1 << 20, so the first checkpoint arrives near 1239 bytes: a 1025-byte value gets zero checks and the count dies with it, leaving that 65,509-row block uninterruptible.function_base58_max_input_sizebounds one value, never the block, and 1025 bytes is well inside its 10 KB default.The change. Both conversions take a defaulted
size_t * shared_work_since_check, bound as a reference so every exit path stays charged.FunctionBaseXXConversion::executeImplowns one counter and threads it through its four row loops. Callback, interval and per-value cadence are unchanged:GenericCancellationIntervalstill reports exactly 63 calls.Validation. Debug, 65536 rows, server elapsed, 50 ms deadline: encode 2097.1 ms to 50.6 ms, decode 509.9 ms to 50.5 ms. Two unit tests: eight sub-threshold values reach 0 checks with a per-call count and 5 with a shared one (encode, decode, and values rejected at their last character), and the two row loops, driven directly, make the same 5, or 0 if a row gets its own count. Only
executeImpl's own counter rests on those numbers.tests/performance/base58.xmlis unchanged.Disclosed, not fixed here.
base58Encodeof exactly 32 or 64 bytes takes a fixed-size encoder with no checkpoint, so such a block keeps ordinary per-block granularity: 10M rows notice a deadline 0.6 s (32 B) or 1.8 s (64 B) late on debug, andSHA2562.1 s late on the same block. Withfunction_base58_max_input_size = 0, an all-zero or all-'1'value of hundreds of megabytes runs an O(n) prologue with no checkpoint. Both are absent checks, not wrong-lifetime counts.Related: #112203
Workflow [PR]
Sync PR [sync-upstream/pr/119388]