Skip to content

Fix QueryConditionCache OOB write on part-name/layout reuse (#2342) - #2355

Open
coreground wants to merge 5 commits into
Altinity:stable-25.8from
coreground:qcc-2342-layout-mismatch-fix
Open

Fix QueryConditionCache OOB write on part-name/layout reuse (#2342)#2355
coreground wants to merge 5 commits into
Altinity:stable-25.8from
coreground:qcc-2342-layout-mismatch-fix

Conversation

@coreground

Copy link
Copy Markdown

Root-cause fix, validated: QueryConditionCache key missing mark layout

Following up on the crash trace package (708 rows across a 14-node production cluster) and the direct QueryConditionCache::write faults reported in #2342.

Mechanism

QueryConditionCache::Key was {table_id, part_name, condition_hash}. A part name can be reused after the underlying part's mark layout changes (exact lifecycle path not confirmed — see Open Items). write()'s cache.getOrSet(key, load_func) can then return a pre-existing Entry sized for the old layout, which the code proceeds to index using the current task's marks_count/mark_ranges:

std::fill(entry->matching_marks.begin() + mark_range.begin,
          entry->matching_marks.begin() + mark_range.end, false);
...
entry->matching_marks[marks_count - 1] = false;

On std::vector<bool>'s bit-packed storage, this is an out-of-bounds write, not a read.

Note: this branch was built starting from the actual v25.8.28.10001.altinitystable production tag, not a later revision — the Key fix below was not yet present there and had to be derived from scratch as part of this work, alongside the defense-in-depth hardening.

Fix

  1. Key now includes marks_count and has_final_mark — two different layouts are structurally two different cache entries, so getOrSet() can no longer return a mismatched Entry.
  2. The size-mismatch checks (write, shared-lock path; write, exclusive-lock path; read; plus final-mark/zero-marks and mark-range-bounds validation) are LOG_ERROR + skip rather than throw, so a residual mismatch degrades to "don't use the cache for this entry" instead of failing the user's query — with chassert retained alongside each, so debug/CI builds still catch a regression. A new QueryConditionCacheLayoutMismatch ProfileEvent surfaces if any of these ever fire.
  3. FilterTransform's buffered-write flush comparison now also checks marks_count/has_final_mark (not just table_uuid/part_name) — this was a second, independent path where a differently-shaped part could otherwise be merged into a stale buffer before ever reaching write().
  4. system.query_condition_cache now exposes marks_count/has_final_mark so entries that previously looked like indistinguishable duplicates are distinguishable.
  5. gtest coverage for layout separation, invalid ranges, reused-part-name-with-different-layout, and concurrent writes.

Validation performed

  • Write-path marks_count/has_final_mark derivation vs. read path — confirmed matching. All 4 real write() call sites (FilterTransform.cpp WHERE path, MergeTreeSelectProcessor.cpp PREWHERE path) derive these from the same live data_part->index_granularity object the read path (MergeTreeDataSelectExecutor.cpp) uses — not a stale copy. Confirmed by static tracing and by a live smoke test: QueryConditionCacheHits > 0 on repeated queries, confirming the fix doesn't silently zero out the cache hit rate.
  • gtests: 4/4 pass, built and run against a real compiled binary.
  • Full release build succeeded with this commit's own pinned toolchain (clang 19.1.7, rust nightly-2025-07-07) after fixing an unrelated, external issue (a stale LLVM apt signing-key hash that no longer matched what apt.llvm.org currently serves — a local build-environment fix, not part of this patch).
  • A working Docker image was built and smoke-tested: boots, answers queries, cache-hit-rate/no-regression check passes; a best-effort attempt to reproduce the original part-name-reuse trigger via ALTER ... DELETE + ALTER ... UPDATE was inconclusive (no crash, but the mutation produced a new part name rather than reusing the original in place, so this likely didn't exercise the actual original trigger).
  • This PR went through an internal final-review pass (11 findings fixed: a misplaced namespace ErrorCodes, two throws this branch had added that should have been log-and-skip like the rest, missing chasserts, a metric double-counting bug, the FilterTransform gap in item 3 above, stale test comments/duplicate test, file permissions, wording, and doc-comment updates).

Newly discovered issue — separate from this fix, reported for visibility

While verifying that the write path's cache-population exclusions mirror the read path's guard (!isFinal() && !mutations_snapshot->hasDataMutations()/hasPatchParts() && !vector_search), a real, independently-verified gap was found: the write path does not fully mirror the read path's guard.

  • The two PREWHERE write call sites (MergeTreeSelectProcessor.cpp) have zero parity with the read-path guard — isFinal(), in-flight mutations/patch parts, and vector search are all unchecked before writing.
  • The two WHERE write call sites (FilterTransform.cpp) correctly guard isFinal(), but not mutations/patch parts or vector search.

Consequence: a query the read path would refuse to consult the cache for can still populate a cache entry via these write paths, and a later "plain" query can read back results computed under different semantics — a silent wrong-results correctness bug, not a crash, and orthogonal to the Key fix above. This is a distinct issue from #2342 and is not fixed by this PR — flagging for a separate follow-up.

Still not resolved, flagged honestly

  • Does not explain why prefer_localhost_replica = 0 independently reduced the crash rate ~19x before the cache was touched. Possible secondary factor: local-read concurrency increasing concurrent write() calls against the same Entry — out of scope for this PR.
  • The exact part-lifecycle sequence that reuses a part name with a different mark layout is still not confirmed live. The Key fix makes this class of bug structurally impossible regardless of the exact trigger, but the trigger itself was not reproduced.

Confidence: high that this closes a concrete cache-owned OOB-write path. Not a claim this was the first or only corruption site in the original crash traces, nor that the prefer_localhost_replica question or the newly-discovered write-path exclusion gap are resolved.

🤖 Generated with Claude Code

https://claude.ai/code/session_013NAYye5ozisLN7WBqzYfAn

Coreground Technologies Automation Account and others added 5 commits September 12, 2026 13:42
…#2342)

The real v25.8.28.10001.altinitystable tag did NOT have the Key fix
upstream (qcc-package's investigation snapshots were captured from a
later revision that already had it — see docs/superpowers/plans/
2026-09-12-qcc-2342-findings.md). This commit adds it: Key now includes
marks_count/has_final_mark so a layout mismatch on getOrSet() is
structurally impossible, updates read()'s signature and its call
site(s) accordingly, and layers the defense-in-depth patch on top: the
three size-mismatch checks in QueryConditionCache::write()/read()
become LOG_ERROR + skip instead of throw, so a residual mismatch
degrades to a cache miss instead of failing the user's query, and a
new QueryConditionCacheLayoutMismatch ProfileEvent surfaces if that
check ever fires.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NAYye5ozisLN7WBqzYfAn
…-depth (Altinity#2342)

Adds two gtest files under src/Interpreters/Cache/tests/ (new directory,
picked up automatically by the existing GLOB_RECURSE "gtest*.cpp" rule
in src/CMakeLists.txt, no CMake changes needed):
- gtest_query_condition_cache.cpp: baseline regression tests for the
  Key layout-mismatch fix (2 tests).
- gtest_query_condition_cache_defense_in_depth.cpp: additional coverage
  for reused part names and concurrent writes (3 tests).

Added <base/UUID.h>, <base/unit.h>, <thread>, and <vector> includes
proactively since the literals/types used (1_MiB, UUID(1), std::thread,
std::vector) are not guaranteed to be visible transitively.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NAYye5ozisLN7WBqzYfAn
The stale TODO comment above `extern const Event QueryConditionCacheLayoutMismatch;`
(copied verbatim from qcc-package's patched .cpp per Task 2's Step 2) had a
trailing " \" at the end of its last `///` line. A trailing backslash performs
line splicing at the preprocessing/translation-phase level even inside line
comments, before tokenization — so that spliced the next physical line (the
extern declaration itself) into the same comment, silently deleting it from
compilation and breaking the build wherever this event is referenced.

Found by Task 5 while building the checkout. Removes the whole 5-line stale
TODO block (it was dead text anyway: the ProfileEvent it describes was
already registered in src/Common/ProfileEvents.cpp in the original Task 2
commit), restoring the extern declaration as live code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NAYye5ozisLN7WBqzYfAn
Addresses all findings from the final whole-branch code review of the
QueryConditionCache marks_count/has_final_mark key fix (Altinity#2342):

1. Move the file-scope `namespace ErrorCodes` block inside `namespace DB`,
   matching the convention used everywhere else in the codebase.
2. Convert the two throws added by this branch in write() (final-mark/zero-marks
   check, and mark-range bounds validation) to the same log-and-skip pattern
   used by the other defense-in-depth checks in this file, and delete the
   inaccurate "unchanged from upstream" comment that justified the throw.
3. Add back `chassert(entry->matching_marks.size() == marks_count)` immediately
   above each of the three log-and-skip mismatch checks, so debug/CI builds
   still abort on a regression while release builds degrade gracefully.
4. Move the QueryConditionCacheHits counter increment in read() to just before
   the final successful return, so a layout mismatch no longer double-counts
   as both a hit and a miss.
5. In FilterTransform::writeIntoQueryConditionCache, also compare marks_count
   and has_final_mark (not just table_uuid/part_name) when deciding whether to
   append to the buffered MarkRangesInfo or flush it, since a part_name can be
   reused with a different mark layout.
6. Add marks_count and has_final_mark columns to system.query_condition_cache
   so entries that differ only in these key fields are distinguishable.
7. Remove the stale/inaccurate header comment in
   gtest_query_condition_cache_defense_in_depth.cpp and delete its verbatim
   duplicate of KeepsEntriesWithDifferentMarkLayoutsSeparate (already covered
   by gtest_query_condition_cache.cpp and linked into the same test binary).
8. chmod 644 both gtest_query_condition_cache*.cpp files to match every other
   gtest source file in the tree.
9. Reword the QueryConditionCacheLayoutMismatch ProfileEvents description to
   describe the observable behavior instead of reading as an internal PR note.
10. Strengthen ReusedPartNameWithFinalMarkDoesNotThrowOrCorrupt to also assert
    on the small layout's actual mark values, not just its size.
11. Reword completed-work comments from TODO(Altinity#2342) to NOTE(Altinity#2342), and update
    QueryConditionCache::read()'s doc comment to describe the marks_count/
    has_final_mark parameters added when its signature grew from 3 to 5 args.

Also updated the RejectsRangesOutsideEntryMarkLayout test to match the write()
throw -> log-and-skip conversion from finding 2 (EXPECT_NO_THROW + read-miss
assertion instead of EXPECT_THROW).

Verified via `unit_tests_dbms --gtest_filter="QueryConditionCache.*"`:
4/4 tests pass (5 tests minus the duplicate removed in finding 7).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NAYye5ozisLN7WBqzYfAn
Both throws that used it were converted to log-and-skip in the
previous commit (15846cb), leaving this extern declaration and the
Common/Exception.h include unused. ClickHouse's style-check CI job
flags unused ErrorCodes declarations, so this would otherwise fail
CI on this PR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NAYye5ozisLN7WBqzYfAn
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant