Account all resources of normalized_query_hash quotas per query hash#107681
Conversation
A quota `KEYED BY normalized_query_hash` is meant to give each distinct query pattern its own bucket. Until now only the query-count counters (`QUERIES`, `QUERY_SELECTS`, `QUERY_INSERTS`) were routed through the per-hash intervals via `usedForQuery`. The remaining counters were fed by the pipeline callbacks, which called `EnabledQuota::used` directly and so accounted against the placeholder per-user intervals: * `READ_ROWS` / `READ_BYTES` from `ReadProgressCallback`, * `RESULT_ROWS` / `RESULT_BYTES` / `EXECUTION_TIME` from `LimitsCheckingTransform`, * `WRITTEN_BYTES` from `CountingTransform` (and its pre-check in `InterpreterInsertQuery`), * `ERRORS` incremented in the exception callbacks of `executeQuery`. As a result those resources behaved as a single global per-user bucket for `NORMALIZED_QUERY_HASH` quotas instead of being split per query pattern. This change routes all of them through `EnabledQuota::usedForQuery` (and a new `checkExceededForQuery`), which resolves the per-hash intervals for `NORMALIZED_QUERY_HASH` quotas and the shared session intervals for every other quota - so behavior is unchanged for non-hash quotas. The normalized query hash is carried to the pipeline accounting via `QueryPipeline` and, for the insert path, via `Context`. Follow-up to #107667 (re-implemented in #107664), addressing the review comment that these resources were not bucketed per hash. A stateless test (`04341_normalized_query_hash_quota_resources`) checks that two structurally different query patterns get independent `read_rows` and `result_rows` buckets. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A user can be governed by several quotas at once (this PR stack enforces all quotas assigned to a user, not only one). `SHOW CREATE QUOTA` without an explicit name routed through the single-valued current-quota path (`Context::getQuotaUsage`) and therefore exposed only whichever quota was returned first from the unordered set of governing quotas, which was inconsistent with enforcement. Route it through `getQuotaUsages` so it lists every quota currently governing the user. Addresses the review comment on `EnabledQuota::getUsage`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`04341_normalized_query_hash_quota_resources` only exercised `read_rows` and `result_rows`, but the change also routes the independently-wired `written_bytes` (the insert `CountingTransform` and its pre-check) and `errors` (the exception callbacks) counters through the per-normalized-query-hash intervals. Add focused cases showing two query patterns get independent buckets for both, so every resource named in the changelog has direct evidence. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the AI review's two required actions (commits bf4c908, b64f595):
Both findings validated locally against a freshly built server. The two CI reds are unrelated flakes: the |
…-quota-all-resources
The async insert flush in `AsynchronousInsertQueue::processData` builds `InterpreterInsertQuery` directly instead of going through `executeQuery`, so it never set `normalized_query_hash` on the flush context. As a result the `WRITTEN_BYTES` pre-check in `InterpreterInsertQuery` and the `CountingTransform` both saw hash `0`, and a `KEYED BY normalized_query_hash` quota accounted every async insert pattern against a single shared bucket instead of one per pattern. Set the hash on the flush context right after it is computed, mirroring what `executeQuery` does for the synchronous path. New test `04403_normalized_query_hash_quota_async_insert` inserts the same data with two structurally different INSERT statements (the `Values` and the `JSONEachRow` formats, which produce different normalized hashes) under a `MAX written_bytes` quota and checks their buckets are independent; it fails if the flush context keeps using hash `0` (verified by reverting the one-line fix). Addresses #107681 (comment) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After merging master, `05019_show_create_quota_lists_all_current_quotas` was far above the highest existing test number (the `05019` slot came from unrelated local working-tree files), so `test_numbers_check` failed with `Gap (4364, 5019) > 100`. Renumber it to `04402`, the next free slot. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the AI review's required action (async-insert
Built locally and re-ran |
…-quota-all-resources # Conflicts: # src/Interpreters/Access/InterpreterShowCreateAccessEntityQuery.cpp
The PR added `Context::normalized_query_hash` as a member with an in-class initializer (`= 0`), but the `ContextData` copy constructor did not list it, so `Context::createCopy` silently reset it to `0` instead of copying it. The INSERT path replaces its context with `Context::createCopy(context)` to disable parallel replicas for the write (`InterpreterInsertQuery.cpp`, the `canUseParallelReplicasOnInitiator` branch). With parallel replicas enabled, that copy therefore lost the hash, so the `WRITTEN_BYTES` accounting in `CountingTransform` charged every distinct INSERT SELECT pattern to the shared hash-`0` bucket. A second, structurally different pattern then inherited the first pattern's already-exhausted bucket and was wrongly rejected with `QUOTA_EXCEEDED`. This is exactly what `04341_normalized_query_hash_quota_resources` and `04403_normalized_query_hash_quota_async_insert` exercised, and they failed deterministically only in the parallel-replicas CI configuration. Read-side counters (`READ_ROWS`/`RESULT_ROWS`) were unaffected because they flow through `pipeline.setNormalizedQueryHash` on the un-copied initiator pipeline. Copy the member in the copy constructor so a copied context keeps the query hash, which is the intended copy semantics. CI report: #107681
…efreshable materialized-view contexts Per-hash quota accounting was lost in derived execution contexts that are built outside the normal `executeQuery` hash propagation: - `SQL SECURITY DEFINER`/`NONE` views and materialized views run their bodies under a context created by `StorageInMemoryMetadata::getSQLSecurityOverriddenContext` from the global context, where `normalized_query_hash` is `0`. The dependent materialized-view insert (`InsertDependenciesBuilder`) and the definer view read (`IInterpreterUnionOrSelectQuery`) therefore charged the definer's `KEYED BY normalized_query_hash` quota to a single shared hash-`0` bucket. Carry the outer query's hash into the overridden context, next to the existing `ProgressCallback`/`ProcessListElement` propagation. - Refreshable materialized views compute the refresh query hash for logging but never set it on `refresh_context`, so `RefreshTask` charged the refresh write (the `WRITTEN_BYTES` pre-check and `CountingTransform`) to hash `0`. Set the computed hash on `refresh_context` before constructing `InterpreterInsertQuery`, mirroring the direct-query and async-insert paths. Together with preserving `normalized_query_hash` in the `Context` copy constructor, this routes the resource counters of these contexts through the per-hash intervals. Adds `04404_normalized_query_hash_quota_sql_security`, which verifies that `written_bytes` of a `SQL SECURITY DEFINER` materialized view is bucketed per the outer insert pattern. Verified that the per-hash bucket key equals the outer insert's `normalizedQueryHash` (i.e. the hash is propagated, not `0`). CI report: #107681
|
Picked this up after #107664 merged into master (the branch was then
All review threads resolved. The two remaining stress-test reds are known master-wide flakes — |
…td::vector` The `EnabledQuota::usedForQuery` overload set had separate two-pair and three-pair `std::pair<QuotaType, QuotaValue>` variants, which was ugly and did not scale. Replace them with a single `usedForQuery(UInt64, const std::vector<std::pair<QuotaType, QuotaValue>> &, bool)` overload, mirroring the existing `EnabledQuota::used` vector overload. The single-counter `usedForQuery(UInt64, QuotaType, QuotaValue, bool)` convenience overload is kept. Call sites in `ReadProgressCallback` and `LimitsCheckingTransform` pass a braced list of usages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
alexey-milovidov
left a comment
There was a problem hiding this comment.
The changes are mostly trivial. LGTM, but see the comments about performance.
…-quota-all-resources
Address review (thread on `QueryPipeline.h:134`): the empirical-scan pipeline in `WhatIfIndexEstimator` called `setQuota` but never set the normalized query hash, so under a `KEYED BY normalized_query_hash` quota the scan's `read_rows`/`read_bytes` were accounted against the shared hash-`0` bucket. Two structurally different `EXPLAIN WHATIF` patterns therefore shared one bucket, and once one exhausted it the other was rejected too. `WhatIfIndexEstimator` now calls `setNormalizedQueryHash` next to `setQuota`, so the scan is accounted against the query's own per-hash bucket. The `Context` copy made in `WhatIfIndexEstimator::run` already preserves the hash. New test `04490_normalized_query_hash_quota_whatif` checks that two structurally different `EXPLAIN WHATIF` patterns get independent `read_rows` buckets. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address review (performance comment on `EnabledQuota.h:66`): the multi-counter `EnabledQuota::usedForQuery` overload took a `std::vector`, while its two hot-path callers — `ReadProgressCallback` and `LimitsCheckingTransform` — pass braced lists on every read-progress and result chunk. That constructed a heap-allocating vector on each call before the `empty` fast path could be reached, and ordinary authenticated contexts usually carry a non-null `EnabledQuota`. Switch the parameter to `std::initializer_list<std::pair<QuotaType, QuotaValue>>`, whose backing array has automatic storage, so the braced lists at the call sites no longer allocate. The single clean signature is preserved (no fixed-arity overload zoo), and behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the AI review's
The previous red was the |
|
CI on the current head
AI review is ✅ Approve on this commit and there are no unresolved review threads, so the PR is ready modulo CI completion and merge. |
|
Re-triggered the failed CI jobs on head `e8736c8897a` (run 28426257065) — the remaining reds are confirmed unrelated flakes:
No re-merge needed (merge-base is from today, <1 day old). AI review is ✅ Approve, all review threads are resolved, and the PR is |
|
The latest CI on head
I re-triggered the failed stress jobs to give them a fresh roll. No re-merge: the merge-base is still <1 day old, and the only PR-touched file AI review is ✅ Approve on this commit and there are no unresolved review threads, so the PR is ready modulo the flaky reruns and merge. |
Keep the branch current (3 days behind) and re-trigger CI. The only CI reds were the known master-wide flake 'Hung check failed, possible deadlock found' (#107941) on amd_tsan/arm_tsan; verified the hung thread is the AST-fuzzer 'formatQuery' query (0 rows read) with no quota frames, unrelated to this change. Merge is conflict-free; net diff vs master is exactly this PR's files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Re-merged The merge was conflict-free — the six PR-touched files that The previous head AI review is ✅ Approve on the prior head and there are no unresolved review threads, so the PR is ready modulo the fresh CI going green and merge. |
The `dictGetKeys` const-path builds its own `ReadProgressCallback` and set the quota but not the normalized query hash, so its dictionary scan charged `READ_ROWS`/`READ_BYTES` to the shared hash-`0` bucket. Under a `KEYED BY normalized_query_hash` quota two structurally different `dictGetKeys` patterns therefore shared one read bucket, contradicting this PR's per-hash contract for the read-progress counters. Propagate the query's normalized hash into that callback, mirroring the earlier fix for the `EXPLAIN WHATIF` empirical-scan path. Add `04498_dict_get_keys_normalized_query_hash_quota` proving two structurally different `dictGetKeys` patterns get independent `read_rows` buckets (`read_rows` and `read_bytes` are routed through the same callback). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the AI review's The re-review surfaced one more direct
Also noticed on this fresh CI roll: the 30+-pass |
|
|
||
| auto transform = std::make_shared<LimitsCheckingTransform>(output->getSharedHeader(), limits); | ||
| transform->setQuota(quota_); | ||
| transform->setNormalizedQueryHash(normalized_query_hash); |
There was a problem hiding this comment.
setLimitsAndQuota still leaves one read-progress path outside quota accounting. This method only wires the LimitsCheckingTransform; it does not populate QueryPipeline::quota, and PullingPipelineExecutor later builds the ReadProgressCallback from pipeline.getReadProgressCallback(), which reads that member. InterpreterWatchQuery::execute is a current caller that only uses setLimitsAndQuota, so its callback still sees quota == nullptr and never calls usedForQuery for READ_ROWS / READ_BYTES.
That means a WATCH query can still bypass MAX read_rows / MAX read_bytes quotas entirely, including the KEYED BY normalized_query_hash case this PR is trying to make fully per-pattern. Please either store quota_ in the pipeline here, or make the watch path call setQuota before execution, and add a focused WATCH regression.
There was a problem hiding this comment.
Confirmed the mechanism: setLimitsAndQuota wires the quota only into the LimitsCheckingTransform (the result-side counters RESULT_ROWS / RESULT_BYTES / EXECUTION_TIME), while getReadProgressCallback reads QueryPipeline::quota, which setLimitsAndQuota does not populate. So a pipeline configured only via setLimitsAndQuota — the WATCH path — gets a read-progress callback with quota == nullptr.
This is pre-existing behavior, not a regression from this change. On master, setLimitsAndQuota is identical (it sets only transform->setQuota), and InterpreterWatchQuery::execute has always called only setLimitsAndQuota, never setQuota. So READ_ROWS / READ_BYTES were never accounted for WATCH via the read-progress path, for any quota kind. This PR only adds setNormalizedQueryHash on that path, which makes the already-accounted result-side counters bucket per hash; it does not change read-side accounting for WATCH.
Concretely, this is not the "resource went to the shared per-user bucket instead of the per-hash bucket" bug this PR fixes — for that, the resource would have to be accounted against some interval, and here READ_ROWS / READ_BYTES for WATCH are not accounted at all.
Storing quota_ in the pipeline in setLimitsAndQuota would newly enable READ_ROWS / READ_BYTES quota enforcement for WATCH, which is a behavior change beyond the scope of this fix. Leaving it out of this PR; it can be a focused follow-up (store quota_ in the pipeline in setLimitsAndQuota, plus a WATCH read-quota regression test).
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 151/157 (96.18%) · Uncovered code |
Resolve the conflict in `InterpreterInsertQuery.cpp` caused by master's `NORMALIZED_QUERY_HASH` quota change (#107681), which added `context->getNormalizedQueryHash()` to the `CountingTransform` constructor call in `buildInsertPipeline`. This PR restructured `buildInsertPipeline` to fan out the write side across parallel sink chains: the `CountingTransform` was relocated from the sink chain (`chain.addSource(counting)`) to the single-stream pipeline head, and the old `chain` block was replaced by the `ResizeProcessor` distribution logic. The resolution keeps the parallel fan-out and propagates master's per-query-hash accounting to the relocated head-stream `CountingTransform`, so `WRITTEN_BYTES` for the new parallel plain-`INSERT` path is bucketed per `NORMALIZED_QUERY_HASH` just like the `INSERT SELECT` path. The disputed fail-close `strict_dedup_single_stream` guard is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Related: #107664
A quota
KEYED BY normalized_query_hashis meant to give each distinct query pattern its own bucket. Until now only the query-count counters (QUERIES,QUERY_SELECTS,QUERY_INSERTS) were routed through the per-hash intervals viaEnabledQuota::usedForQuery. The remaining counters were fed by the pipeline callbacks, which calledEnabledQuota::useddirectly and therefore accounted against the placeholder per-user intervals:READ_ROWS/READ_BYTESfromReadProgressCallback,RESULT_ROWS/RESULT_BYTES/EXECUTION_TIMEfromLimitsCheckingTransform,WRITTEN_BYTESfromCountingTransform(and its pre-check inInterpreterInsertQuery),ERRORSincremented in the exception callbacks ofexecuteQuery.As a result, for
NORMALIZED_QUERY_HASHquotas these resources behaved as a single global per-user bucket instead of being split per query pattern.This change routes all of them through
EnabledQuota::usedForQuery(and a newcheckExceededForQuery), which resolves the per-hash intervals forNORMALIZED_QUERY_HASHquotas and the shared session intervals for every other quota — so behavior is unchanged for non-hash quotas. The normalized query hash is carried to the pipeline accounting viaQueryPipelineand, for the insert path, viaContext.This addresses the review comment on #107664 that these resources were not bucketed per hash. It is stacked on top of #107664 (it depends on its
usedForQuerymachinery) and targets that branch; GitHub will retarget it tomasteronce #107664 is merged.Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Quotas keyed by
normalized_query_hashnow account all resources (read_rows,read_bytes,result_rows,result_bytes,execution_time,written_bytes,errors) per query pattern, like the query-count counters, instead of accounting them against a single shared per-user bucket.🤖 Generated with Claude Code
Version info
26.7.1.508