Fast single-user look-up in system.users#96699
Conversation
|
Seems nice as you don't need to store more data for this optimization. Everything is already there. |
|
Any blockers on merging this, @qoega ? |
|
|
||
| /// If the predicate is a simple `name = 'literal'`, extract the literal string. | ||
| /// Returns std::nullopt for any other predicate shape — the caller falls back to the full scan. | ||
| std::optional<String> tryExtractNameFromPredicate(const ActionsDAG::Node * predicate) |
There was a problem hiding this comment.
Thanks! This is a good change!
I also remember that we have a generic way to do this using VirtualColumnUtils. I will search for it...
There was a problem hiding this comment.
I checked — VirtualColumnUtils::filterBlockWithPredicate works the other way around: it takes a full list and filters it down. There is no generic utility to extract specific values from a predicate without having the full list first. StorageSystemZooKeeper has its own bespoke extractPathImpl for the same purpose. So the author's approach here (custom tryExtractNameFromPredicate) is the right pattern for an O(1) lookup.
Use the same approach as `StorageSystemZooKeeper::extractPathImpl`:
handle `name = 'literal'`, `name IN ('a', 'b')`, and `AND` conjunctions
for O(1) user lookups, instead of only bare equality.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
It was needed for `VirtualColumnUtils` filtering, which is not used here — predicate extraction handles everything directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…llData` Without this override, `IStorageSystemOneBlock::applyFilters` never builds the filter DAG for this table, so `predicate` is always nullptr and the fast O(1) lookup path is never reached. Also remove fragile `query_log` read_rows assertions from the test — the `read_rows` count for system table queries depends on too many factors (randomized settings, parallel replicas, etc.) to be reliably checked. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This is a deterministic way to assert the optimization works: the query will fail if more rows are read than expected. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use clickhouse-local for a fully isolated environment with no pre-existing users (except default). This removes the need for no-parallel tag and makes the test deterministic. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Thanks for pushing this @alexey-milovidov ! 🙏 |
|
@alexey-milovidov , fuzzer came out with: Do you concur, that we should be more strict about the optimization? |
…s-predicate-pushdown
Required because the gh OAuth token used to push to the fork lacks the `workflow` scope to update `.github/workflows/*` files brought in by the master merge.
Workflow files in this branch were stale or had been deleted as a workaround for GitHub's workflow-scope check. Restoring them to match `master` so the PR diff no longer contains those unrelated changes.
|
The review is on point. Let's first construct a test to demonstrate the incorrect behavior, then find a way to fix it. |
`isNameNode` accepted any node whose `result_name` is `name` after stripping aliases. Tighten it to also require `ActionsDAG::ActionType::INPUT`, so the fast path keys off the genuine `name` column from `getFilterSampleBlock` rather than anything that merely shares the name. This is a precision/robustness hardening: the filter sample block exposes `name` as an `INPUT` node, so the legitimate fast path is unaffected, and predicates over a column named `name` that is not the input fall back to the full scan. Also add a regression query exercising a constant alias named `name` that shadows the column (`SELECT 'alice' AS name FROM system.users WHERE name = 'alice'`), which must return all users like the full scan. Addresses a review comment by clickhouse-gh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Pushed
Honest caveat: I couldn't reproduce an actual behavior divergence for that query against the pre-change code (the |
The fast path for `SELECT ... FROM system.users WHERE name = 'x'` / `name IN (...)` looks up each requested name in every access storage. For a single user or a short `IN` list this is far cheaper than `findAll<User>`, but a very large requested set (a huge literal list or a subquery result) could perform more lookups than a single full scan, making the "fast" path slower than the fallback. Cap the fast path at a deliberate limit of names; above it we fall back to the full scan, which is exactly the behavior before this optimization, so the fast path can never become slower than the original full scan. Addresses a review comment on the pull request. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed the Bugbot review comment about the unbounded The fast path looked up each requested name in every access storage, so a very large The limit value is a heuristic — there is no cheap way to count users without Extended @alexey-milovidov your own |
The `name = 'literal'` fast path was guarded by `value->column->size() != 1`. But `ActionsDAG::addColumn` normalizes a constant node to a `ColumnConst` of size 0 (see the comment on `ActionsDAG::Node::column`), so the guard rejected every literal and `extractNamesFromPredicateImpl` returned `nullopt` — sending the headline `name = 'literal'` lookup back to `findAll<User>`. The optimization never actually ran for equality. Read the underlying value directly without a size guard, exactly like `StorageSystemZooKeeper` does for its `path` predicate. Also bound the `IN` fast path before materializing a large set: as soon as the set exceeds `max_names_for_fast_path`, return `nullopt` and fall back to the full scan instead of copying every element into `names` first. This keeps the promise that the fast path is never more expensive than the original behavior. `max_names_for_fast_path` is hoisted to the anonymous namespace so extraction and `fillData` share the same limit. The previous stateless test could not catch the equality regression: its output is identical whichever path runs, and the `max_rows_to_read` assertions it relied on are vacuous because that limit is not enforced for the single-chunk `ReadFromSystemOneBlock` source. Rework it to run against the server and witness which path ran via `system.query_log` `read_rows` (the fast path materializes only the matching users, the full scan materializes every user). Names are uniquely prefixed for parallel safety. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Pushed 6f64502 addressing the two automated review findings: 1. Equality fast path was broken (the headline feature). The 2. Test reworked to actually witness the fast path. The previous test could not catch issue (1): the output is identical whichever path runs, and its The only red CI check is |
The style check requires that any test querying `system.query_log` or `system.query_thread_log` includes a `current_database = currentDatabase()` condition, so it does not read entries written by concurrently running tests. The `read_rows` helper in `03914_system_users_predicate_pushdown.sh` filtered only by a unique `query_id`, which was effectively isolated but did not satisfy the textual style check. The measured queries run in `$CLICKHOUSE_DATABASE`, so the added condition does not change the result. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s-predicate-pushdown
…em.users fast path Addresses the remaining automated review findings on the `system.users` name-predicate fast path: 1. NULL string constants no longer raise an exception. A `Nullable(String)` constant passes the type check, but `name = CAST(NULL, 'Nullable(String)')` would reach `ColumnNullable::getDataAt`, which throws on a `NULL`. The equality path now treats a `NULL` constant as an unsatisfiable predicate (empty candidate set, so the fast path emits nothing), and the `IN` path skips `NULL` elements. Both match the full-scan semantics of returning no rows for such predicates. 2. A large literal `name IN (...)` no longer materializes the whole right-hand side before falling back. The set size is now checked via the already-built set's `getTotalRowCount` before calling `buildOrderedSetInplace`, which (with the default `use_index_for_in_with_subqueries_max_values = 0`) would otherwise duplicate every element only for the cap at `elements->size()` to send it to the full scan. The post-build size check is kept for set kinds whose size is known only after building (e.g. subquery sets). Extended `03914_system_users_predicate_pushdown` with focused coverage for `name = NULL` and `name IN (NULL, ...)` proving the fast path preserves full-scan semantics without raising an exception. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Pushed 1. NULL string constants no longer raise an exception. A 2. A large literal Tests. Extended The edited translation unit compiles cleanly; the change touches only stable The remaining red CI checks are unrelated: |
Addresses the remaining automated review finding on the `system.users`
name-predicate fast path.
The large-`IN` guard checked the *deduplicated* row count
(`Set::getTotalRowCount`), but building the explicit set elements
(`buildOrderedSetInplace` -> `Set::appendSetElements`) filters the *original*
right-hand side columns, so its cost is proportional to the original list
length, not the distinct count. A duplicate-heavy or mostly-`NULL` literal such
as `name IN ('x', 'x', ..., 'y')` could therefore stay under the limit by
distinct count yet still force a scan of the whole right-hand side, making the
fast path slower than the full scan it was meant to beat.
`FutureSetFromTuple` now exposes `getInputRowCount` (the original right-hand
side length, available in O(1) from the deduplication filter built at
construction). The `IN` fast path bounds on it before `buildOrderedSetInplace`
and falls back to the full scan above `max_names_for_fast_path`, like the
existing distinct-count and post-build guards.
Extended `03914_system_users_predicate_pushdown` with a duplicate-heavy `IN`
(few distinct values, long list) - asserting via `read_rows` that it falls back
to the full scan - and a mostly-`NULL` `IN`, both proving correctness is
preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the automated review's remaining finding (the The large-
|
LLVM Coverage ReportChanged lines: Changed C/C++ lines covered by tests: 97/116 (83.62%) | Lost baseline coverage: none · Uncovered code |

Make look-up of a single user O(1) when using system.users with a literal name to do an existence check.
SELECT 1 FROM system.users WHERE name = 'user1') fast on servers with 1000s of usersSHOW GRANTS FOR, which is O(1) but generates a lot of log noise when the user doesn't exist.This change looks for the literal name predicate in the incoming query, and if it finds one, short-circuits the "load the entire list of all users", and fetches just the user we care about.
Changelog category:
Changelog entry:
Improves the performance of fetching a single user record from system.users when the server has many users.
Note
Medium Risk
Medium risk because it changes
system.usersfiltering behavior by parsingActionsDAGpredicates (includingINsets) and could miss/incorrectly handle edge predicate forms, though it falls back to full scans when extraction fails.Overview
system.usersnow detects simplename = 'literal'andname IN (...)predicates and performs directAccessControllookups instead of iteratingfindAll<User>(), improving existence-check and small-name-filter query performance.This introduces predicate parsing helpers (handling
AND, aliases, and explicitINsets), wiresfillData()to receive the predicate, and addsgetFilterSampleBlock()for thenamecolumn; a new stateless test covers fast-path and fallback behavior.Reviewed by Cursor Bugbot for commit f750328. Bugbot is set up for automated code reviews on this repo. Configure here.
Version info
26.7.1.77