Skip to content

Enable reading in reverse order with FINAL for ReplacingMergeTree - #111609

Draft
cwurm wants to merge 11 commits into
ClickHouse:masterfrom
cwurm:final-reverse-read-in-order
Draft

Enable reading in reverse order with FINAL for ReplacingMergeTree#111609
cwurm wants to merge 11 commits into
ClickHouse:masterfrom
cwurm:final-reverse-read-in-order

Conversation

@cwurm

@cwurm cwurm commented Jul 23, 2026

Copy link
Copy Markdown
Member

Closes: #58035
Related: #58361

Second attempt at #58361, this time scoped to ReplacingMergeTree only.

When a query with FINAL sorts in reverse order of the sorting key (e.g. ORDER BY key DESC LIMIT n), the read-in-order optimization now applies instead of falling back to a full read.

ReplacingSortedAlgorithm learns a read_in_reverse mode: a row with a strictly higher version always replaces the selected one; among rows with equal (or absent) versions, the previously selected row is kept unless the current row comes from a newer data part. This mirrors the "last written row wins" rule of the direct reading order, because in the reverse reading order rows within one part arrive backwards while parts still arrive from the oldest to the newest one. This addresses the correctness concern that stopped the first attempt (#58361 (comment)): duplicate keys inside a single level-0 part now select the same row in both reading directions.

The other engines keep the old behavior (no reverse reading with FINAL), since their merging algorithms rely on the direct order of rows: the sequence of sign rows in CollapsingMergeTree, the order of rows fed to order-dependent aggregate functions in AggregatingMergeTree, etc.

On a 110M-row ReplacingMergeTree table (two overlapping parts), SELECT x FROM t FINAL ORDER BY x DESC LIMIT 1:

optimization on optimization off
Rows read 1.71 million 110.1 million
Wall time 0.22 s 4.5 s
Peak memory 23 MB 41 MB

Trade-offs: like the already-existing direct-order in-order reads with FINAL, an in-order plan disables vertical FINAL and the splitting of parts ranges into intersecting and non-intersecting ones (the plain parallel read of non-intersecting ranges does not produce key-sorted streams). A full-result ORDER BY key DESC query without a small LIMIT on a wide or mostly-merged table may therefore regress; the new setting optimize_read_in_reverse_order_final (default true) allows disabling the optimization, and compatibility with versions before 26.7 restores the previous plans.

Changelog category (leave one):

  • Performance Improvement

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Enable the read-in-order optimization for queries with FINAL that sort in reverse order of the sorting key on ReplacingMergeTree tables. This makes queries such as SELECT ... FROM t FINAL ORDER BY key DESC LIMIT n read only the relevant tail of the data instead of the whole table. Can be disabled with the new setting optimize_read_in_reverse_order_final.

Second attempt at ClickHouse#58361,
scoped to ReplacingMergeTree only. Addresses
ClickHouse#58035.

When a query with FINAL sorts in reverse order of the sorting key
(e.g. ORDER BY key DESC LIMIT n), the read-in-order optimization now
applies instead of falling back to a full read.

ReplacingSortedAlgorithm learns a read_in_reverse mode: a row with a
strictly higher version always replaces the selected one; among rows
with equal (or absent) versions, the previously selected row is kept
unless the current row comes from a newer data part. This mirrors the
"last written row wins" rule of the direct reading order, because in
the reverse reading order rows within one part arrive backwards while
parts still arrive from the oldest to the newest one.

The other engines keep the old behavior (no reverse reading with
FINAL), since their merging algorithms rely on the direct order of
rows: the sequence of sign rows in CollapsingMergeTree, the order of
rows fed to order-dependent aggregate functions in
AggregatingMergeTree, etc.

The new setting optimize_read_in_reverse_order_final (default true)
allows disabling the optimization.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@clickhouse-gh

clickhouse-gh Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [4b0c5c8]

Summary:

job_name test_name status info comment
AST fuzzer (amd_debug, targeted, old_compatibility) FAIL
Assertion `px != 0' failed (STID: 2982-48f0) FAIL cidb
Stateless tests (amd_debug, parallel) FAIL
04357_distributed_alias_same_expression FAIL cidb
Stateless tests (arm_binary, parallel) FAIL
04357_distributed_alias_same_expression FAIL cidb
Integration tests (amd_msan, 4/8) FAIL
test_keeper_dynamic_log_level/test.py::test_adjust_log_level FAIL cidb
Stress test (arm_msan) FAIL
Hung check failed, possible deadlock found FAIL cidb

AI Review

Summary

This PR enables reverse-order read-in-order planning for ReplacingMergeTree queries with FINAL, wires the new capability through the relevant planner gates, and adds focused correctness and plan coverage for plain reads, JOIN, and Merge wrappers. After reviewing the current diff, the updated tests, the existing review threads, and the green CI run, I did not find an outstanding correctness, compatibility, or evidence gap that still warrants an inline finding.

Final Verdict

Status: ✅ Approve

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.60% 86.60% +0.00%
Functions 91.90% 91.90% +0.00%
Branches 78.80% 78.80% +0.00%

Changed lines: Changed C/C++ lines covered: 174/179 (97.21%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-performance Pull request with some performance improvements label Jul 23, 2026
Comment thread src/Processors/QueryPlan/ReadFromMergeTree.cpp Outdated
cwurm and others added 3 commits July 30, 2026 13:35
…lacingMergeTree

ReadFromMerge::requestReadingInOrder kept the old blanket guard against
reverse order with FINAL, so a Merge table over ReplacingMergeTree
children could not use the optimization. Replace the guard with a
precise upfront check that every selected child table is a
ReplacingMergeTree: the check must happen before delegating to the
children, because the delegation loop switches the children to in-order
reading one by one, and a child must not be left switched when a later
child rejects the request.

Addresses the review comment
ClickHouse#111609 (comment).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y block

The 26.8 release cycle started on master, and the style check requires
new settings to be recorded under the current version block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/Storages/StorageMerge.cpp
A nested Merge table does not receive the read-in-order optimization in
any direction, because recursivelyApplyToReadingSteps does not descend
into the child plans of a nested ReadFromMerge (they are not plan-node
children). The query falls back to the unoptimized plan and stays
correct. Pin this behavior with a test.

Related to the review comment
ClickHouse#111609 (comment).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/Processors/QueryPlan/ReadFromMergeTree.cpp Outdated
@clickhouse-gh

clickhouse-gh Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.50% 86.50% +0.00%
Functions 91.90% 91.90% +0.00%
Branches 78.70% 78.70% +0.00%

Changed lines: Changed C/C++ lines covered: 150/153 (98.04%) · Uncovered code

Full report · Diff report

cwurm and others added 3 commits August 4, 2026 13:43
topKThroughJoin chooses between itself and the second-pass read-in-order
through the join, and blocked the deferral for every descending FINAL
sort, because requestReadingInOrder used to reject a reverse direction
with FINAL unconditionally. A query like
`... FROM replacing FINAL LEFT JOIN ... ORDER BY key DESC LIMIT n`
therefore kept the injected Sort + Limit over a full read instead of
reading in reverse order.

Extract the engine and setting check into
ReadFromMergeTree::canReadInReverseOrderWithFinal so that both
requestReadingInOrder and topKThroughJoin use one source of truth, and
relax the guard accordingly. The conservative any_desc heuristic stays
in place for the engines that do not support reading in reverse order.

The test compares the rows selected by FINAL behind a join against a
read with both optimizations disabled, for duplicates within a level-0
part, duplicates across parts, version ties, is_deleted, and descending
sorting keys, so that a reverse read cannot silently pick another row of
a duplicate key group.

Addresses the review comment
ClickHouse#111609 (comment).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The test pins that topKThroughJoin does not defer to a second pass that
would reject the read, using a ReplacingMergeTree table sorted in
descending order. Reading in reverse order with FINAL is now supported
for that engine, so the second pass accepts it and the deferral is
sound. Pin optimize_read_in_reverse_order_final = 0 to keep the test
exercising a rejected read, which keeps its reference unchanged. The
accepted case is covered by
04657_top_k_through_join_final_reverse_order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/Processors/QueryPlan/Optimizations/topKThroughJoin.cpp Outdated
topKThroughJoin decided whether the second pass would reject a reverse
FINAL read from the sort description alone (any column descending). That
misses the sorting key's per-column reverse flags: a table ordered by
`k DESC` queried as `ORDER BY k ASC` reads in reverse order even though
no sort column is descending, so the deferral fired, the second pass
rejected the read, and both optimizations were lost. It also blocked the
deferral for the opposite case, a descending sort description of a
descending sorting key, which is a direct read the second pass accepts.

wouldReadInOrderBeUseful already computed the input order and reduced it
to a bool, so return it instead (as getInputOrderIfReadInOrderIsUseful)
and gate on `direction != 1`, the same value the second pass uses.

Because the plan shape of a sound deferral and of one whose second pass
rejects the read afterwards is the same, the new test cases also count
the reading types of the plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/Processors/QueryPlan/Optimizations/topKThroughJoin.cpp
cwurm and others added 2 commits August 8, 2026 16:59
…ehind a join

Covers the interaction of the reverse order FINAL read with
ReadFromMerge::requestReadingInOrder and the read-in-order through join
pass: with topKThroughJoin disabled, the second pass reads the children
of a Merge table in reverse order through the join, and the selected
rows must match a read with no optimization at all.

topKThroughJoin itself never defers for a Merge table, because it looks
for a MergeTree read on the preserved input while a Merge table reads
through its own step, so it wins the plan by default. That predates this
change and holds for every reading direction, with and without FINAL;
the test pins the current behavior rather than changing it.

Related to the review comment
ClickHouse#111609 (comment).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Measured in CI: with topKThroughJoin enabled, the Merge table plan has
one reverse order read, not none. The optimization injects its
Sort + Limit because it does not defer for a Merge table, but reading in
order then satisfies that injected sort, so the children are read in
reverse order anyway and the missing deferral only costs the extra sort
step.

Reference of https://s3.amazonaws.com/clickhouse-test-reports/PRs/111609/1b48419047f4cb65b18d854abdc2130f04f2e880/fast_test.html

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-performance Pull request with some performance improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FINAL reads all data when sorting in reverse order

1 participant