Skip to content

Commit 4530bba

Browse files
committed
address review comments
1 parent 32b3b5c commit 4530bba

3 files changed

Lines changed: 216 additions & 26 deletions

File tree

datafusion/physical-optimizer/src/window_topn.rs

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,18 @@ use datafusion_physical_plan::windows::{BoundedWindowAggExec, WindowUDFExpr};
108108
/// - Config flag `enable_window_topn` is `true`
109109
/// - The plan matches `FilterExec → [ProjectionExec] → BoundedWindowAggExec → SortExec`
110110
/// - The window function is `ROW_NUMBER`, `RANK`, or `DENSE_RANK`
111+
/// - Every window expression in the `BoundedWindowAggExec` is `ROW_NUMBER`,
112+
/// `RANK`, or `DENSE_RANK` over the same `PARTITION BY` / `ORDER BY`. A
113+
/// sibling that reads pruned rows (e.g. `LEAD`, or an aggregate whose
114+
/// frame is not strictly backward-looking) would be computed over the
115+
/// pruned input and give wrong results.
111116
/// - The window function has a `PARTITION BY` clause (global top-K is
112117
/// already handled by `SortExec` with `fetch`)
113-
/// - For `RANK` / `DENSE_RANK`: a non-empty `ORDER BY` clause (otherwise
114-
/// all rows tie at rank 1 — the optimization is useless and the
115-
/// retained set would be unbounded)
118+
/// - At least one `ORDER BY` key survives past the `PARTITION BY` prefix
119+
/// (so the operator has a non-empty ORDER BY). This rejects both a
120+
/// missing `ORDER BY` and one fully covered by the partition prefix
121+
/// such as `PARTITION BY pk ORDER BY pk`; for `RANK` / `DENSE_RANK`
122+
/// such orderings also make every row tie at rank 1 (degenerate).
116123
/// - The filter predicate compares the window output column to an integer
117124
/// literal using `<=`, `<`, `>=`, or `>`
118125
///
@@ -169,6 +176,28 @@ impl WindowTopN {
169176
}
170177
let fn_kind = supported_window_fn(&window_exprs[window_expr_idx])?;
171178

179+
// Tail-pruning drops the rows that rank after the retained top-K,
180+
// and every window expression in this `BoundedWindowAggExec` is
181+
// then evaluated over the *pruned* input. The rewrite is only valid
182+
// if each expression's value for a *retained* row is unaffected by
183+
// the dropped rows. ROW_NUMBER / RANK / DENSE_RANK over the same
184+
// PARTITION BY / ORDER BY satisfy this — each depends only on rows
185+
// at or before the current row in that order, all of which are
186+
// retained. A sibling like `LEAD(x)` reads following (pruned) rows,
187+
// so at the retained boundary it would resolve to a dropped row and
188+
// give a wrong result. Bail out unless every window expression is a
189+
// supported ranking function sharing the matched expression's
190+
// partition/order keys.
191+
let matched_expr = &window_exprs[window_expr_idx];
192+
let all_prune_safe = window_exprs.iter().all(|e| {
193+
supported_window_fn(e).is_some()
194+
&& e.partition_by() == matched_expr.partition_by()
195+
&& e.order_by() == matched_expr.order_by()
196+
});
197+
if !all_prune_safe {
198+
return None;
199+
}
200+
172201
// Step 5: child of window is SortExec (verified above)
173202
let sort_child = sort_exec.input();
174203

@@ -182,12 +211,21 @@ impl WindowTopN {
182211
return None;
183212
}
184213

185-
// For RANK / DENSE_RANK: an empty ORDER BY makes every row tie
186-
// at rank 1 — the optimization is degenerate (we'd retain the
187-
// entire input) and tie storage would be unbounded.
188-
if matches!(fn_kind, WindowFnKind::Rank | WindowFnKind::DenseRank)
189-
&& window_exprs[window_expr_idx].order_by().is_empty()
190-
{
214+
// `PartitionedTopKExec` derives its ORDER BY keys from the
215+
// SortExec ordering *beyond* the partition prefix
216+
// (`expr[partition_prefix_len..]`). That slice is empty in two
217+
// cases:
218+
// * no ORDER BY at all (e.g. `ROW_NUMBER() OVER (PARTITION BY pk)`);
219+
// * ORDER BY keys fully covered by the partition prefix (e.g.
220+
// `DENSE_RANK() OVER (PARTITION BY pk ORDER BY pk)`, whose
221+
// deduplicated sort ordering is just `[pk]`).
222+
// With zero order keys the operator panics on execution (it
223+
// requires at least one), and for RANK / DENSE_RANK every row
224+
// would tie at rank 1 (a degenerate, unbounded retained set).
225+
// `order_by()` alone does not catch the second case — it reports
226+
// `[pk]` even though no order key survives past the partition
227+
// prefix — so guard on the effective order-key count instead.
228+
if sort_exec.expr().len() <= partition_prefix_len {
191229
return None;
192230
}
193231

datafusion/physical-plan/src/topk/mod.rs

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1819,6 +1819,14 @@ impl PartitionedTopKRank {
18191819
/// `RecordBatch`. `TieEntry` is reused verbatim from [`RankPartitionState`].
18201820
struct DenseRankPartitionState {
18211821
groups: HashMap<Vec<u8>, Vec<TieEntry>>,
1822+
/// Cached admission boundary: the largest tracked ob value once
1823+
/// `groups` is full. A `HashMap` is unordered, so finding it otherwise
1824+
/// costs an O(K) scan of the keys on *every* new distinct ob value —
1825+
/// O(N·K) overall on mostly-distinct input. Caching makes the common
1826+
/// "new ob is worse than the boundary → drop" path O(1); the O(K) scan
1827+
/// is paid only when the cache is stale (`None`) — after a fill or an
1828+
/// eviction changes the key set.
1829+
max_key: Option<Vec<u8>>,
18221830
}
18231831

18241832
impl DenseRankPartitionState {
@@ -1839,7 +1847,7 @@ impl DenseRankPartitionState {
18391847
.sum::<usize>()
18401848
})
18411849
.sum();
1842-
table_overhead + contents
1850+
table_overhead + contents + self.max_key.as_ref().map_or(0, |k| k.capacity())
18431851
}
18441852
}
18451853

@@ -1869,8 +1877,9 @@ impl DenseRankPartitionState {
18691877
/// new `TieEntry` (one entry per contributing batch).
18701878
/// - `ob_key` new, `state.groups.len() < k` → insert the run as a new
18711879
/// group.
1872-
/// - `ob_key` new, `state.groups.len() == k` → find the current max via
1873-
/// an O(K) scan of `state.groups.keys()`:
1880+
/// - `ob_key` new, `state.groups.len() == k` → the largest tracked ob
1881+
/// value is the admission boundary, cached in `max_key` (recomputed via
1882+
/// an O(K) scan of `state.groups.keys()` only when the cache is stale):
18741883
/// - `ob_key < max` → remove the max key (evict the entire max-key
18751884
/// group — up to many rows) and insert the run. The evicted group's
18761885
/// row count is added to the `row_replacements` metric.
@@ -2024,6 +2033,7 @@ impl PartitionedTopKDenseRank {
20242033
.entry(pk)
20252034
.or_insert_with(|| DenseRankPartitionState {
20262035
groups: HashMap::new(),
2036+
max_key: None,
20272037
});
20282038

20292039
// Bucket by ob key. `ob_runs` is a reused scratch map (taken
@@ -2061,22 +2071,30 @@ impl PartitionedTopKDenseRank {
20612071
batch_bytes: input_batch_bytes,
20622072
}],
20632073
);
2074+
// A new distinct key may raise the boundary; drop the
2075+
// cached max so it is recomputed on the next Case C.
2076+
state.max_key = None;
20642077
continue;
20652078
}
20662079

2067-
// Case C: new ob, at K distinct keys. Find the current
2068-
// max (K-th distinct-best) via an O(K) scan — cold path.
2069-
// Scoped so the immutable borrow ends before mutation.
2070-
let evict_key: Option<Vec<u8>> = {
2071-
let max_key = state
2072-
.groups
2073-
.keys()
2074-
.map(|key| key.as_slice())
2075-
.max()
2076-
.expect("state.groups has k >= 1 keys");
2077-
(ob_key.as_slice() < max_key).then(|| max_key.to_vec())
2078-
};
2079-
if let Some(evicted_key) = evict_key {
2080+
// Case C: new ob, at K distinct keys. The largest tracked
2081+
// ob value is the admission boundary; it is cached in
2082+
// `max_key` and recomputed via an O(K) scan only when the
2083+
// cache is stale, so the common "ob >= max → drop" path is
2084+
// O(1). Scoped so the immutable borrow ends before mutation.
2085+
if state.max_key.is_none() {
2086+
state.max_key = state.groups.keys().max().cloned();
2087+
}
2088+
let is_smaller = state
2089+
.max_key
2090+
.as_deref()
2091+
.map(|max_key| ob_key.as_slice() < max_key)
2092+
.expect("state.groups has k >= 1 keys");
2093+
2094+
if is_smaller {
2095+
// Evict the entire max-key group and drop the cached
2096+
// max (recomputed on the next Case C).
2097+
let evicted_key = state.max_key.take().expect("max key present");
20802098
let evicted =
20812099
state.groups.remove(&evicted_key).expect("max key present");
20822100
replacements +=
@@ -2139,7 +2157,7 @@ impl PartitionedTopKDenseRank {
21392157
let mut coalescer = BatchCoalescer::new(Arc::clone(&schema), batch_size);
21402158

21412159
for pk in sorted_pks {
2142-
let DenseRankPartitionState { groups } =
2160+
let DenseRankPartitionState { groups, max_key: _ } =
21432161
states.remove(&pk).expect("key from states.keys()");
21442162
// HashMap is unordered — sort the <= K distinct ob keys so
21452163
// rows emit ascending (byte-comparable encoding == sort order).

datafusion/sqllogictest/test_files/window_topn.slt

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,9 +329,115 @@ physical_plan
329329
04)------SortExec: expr=[pk@1 ASC NULLS LAST, val@2 ASC NULLS LAST], preserve_partitioning=[false]
330330
05)--------DataSourceExec: partitions=1, partition_sizes=[1]
331331

332+
# Sibling safety: an aggregate sibling (SUM) is computed over the pruned
333+
# input, so the rule must NOT fire even though the filter is on ROW_NUMBER.
334+
# SUM over the default RANGE frame includes tie-peers that pruning would
335+
# drop, giving a wrong sum. The plan keeps SortExec + FilterExec — no
336+
# PartitionedTopKExec.
337+
query TT
338+
EXPLAIN SELECT * FROM (
339+
SELECT *,
340+
ROW_NUMBER() OVER (PARTITION BY pk ORDER BY val) as rn,
341+
SUM(val) OVER (PARTITION BY pk ORDER BY val) as running_sum
342+
FROM window_topn_t
343+
) WHERE rn <= 3;
344+
----
345+
physical_plan
346+
01)ProjectionExec: expr=[id@0 as id, pk@1 as pk, val@2 as val, row_number() PARTITION BY [window_topn_t.pk] ORDER BY [window_topn_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW@3 as rn, sum(window_topn_t.val) PARTITION BY [window_topn_t.pk] ORDER BY [window_topn_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW@4 as running_sum]
347+
02)--FilterExec: row_number() PARTITION BY [window_topn_t.pk] ORDER BY [window_topn_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW@3 <= 3
348+
03)----BoundedWindowAggExec: wdw=[row_number() PARTITION BY [window_topn_t.pk] ORDER BY [window_topn_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: Field { "row_number() PARTITION BY [window_topn_t.pk] ORDER BY [window_topn_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW": UInt64 }, frame: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, sum(window_topn_t.val) PARTITION BY [window_topn_t.pk] ORDER BY [window_topn_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: Field { "sum(window_topn_t.val) PARTITION BY [window_topn_t.pk] ORDER BY [window_topn_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW": nullable Int64 }, frame: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW], mode=[Sorted]
349+
04)------SortExec: expr=[pk@1 ASC NULLS LAST, val@2 ASC NULLS LAST], preserve_partitioning=[false]
350+
05)--------DataSourceExec: partitions=1, partition_sizes=[1]
351+
352+
# Sibling safety: a LEAD sibling reads following (pruned) rows, so the
353+
# rule must NOT fire — the boundary row's LEAD would resolve to a pruned
354+
# row. The plan keeps SortExec + FilterExec.
355+
query TT
356+
EXPLAIN SELECT * FROM (
357+
SELECT *,
358+
ROW_NUMBER() OVER (PARTITION BY pk ORDER BY val) as rn,
359+
LEAD(val) OVER (PARTITION BY pk ORDER BY val) as next_val
360+
FROM window_topn_t
361+
) WHERE rn <= 3;
362+
----
363+
physical_plan
364+
01)ProjectionExec: expr=[id@0 as id, pk@1 as pk, val@2 as val, row_number() PARTITION BY [window_topn_t.pk] ORDER BY [window_topn_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW@3 as rn, lead(window_topn_t.val) PARTITION BY [window_topn_t.pk] ORDER BY [window_topn_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW@4 as next_val]
365+
02)--FilterExec: row_number() PARTITION BY [window_topn_t.pk] ORDER BY [window_topn_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW@3 <= 3
366+
03)----BoundedWindowAggExec: wdw=[row_number() PARTITION BY [window_topn_t.pk] ORDER BY [window_topn_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: Field { "row_number() PARTITION BY [window_topn_t.pk] ORDER BY [window_topn_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW": UInt64 }, frame: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, lead(window_topn_t.val) PARTITION BY [window_topn_t.pk] ORDER BY [window_topn_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: Field { "lead(window_topn_t.val) PARTITION BY [window_topn_t.pk] ORDER BY [window_topn_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW": nullable Int32 }, frame: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW], mode=[Sorted]
367+
04)------SortExec: expr=[pk@1 ASC NULLS LAST, val@2 ASC NULLS LAST], preserve_partitioning=[false]
368+
05)--------DataSourceExec: partitions=1, partition_sizes=[1]
369+
332370
statement ok
333371
SET datafusion.explain.physical_plan_only = false;
334372

373+
# Sibling safety (correctness): LEAD reads following rows, so if the rule
374+
# fired, the pruned input would give a wrong LEAD at the retained boundary
375+
# — each rn=2 row's next_val would become NULL instead of the (pruned)
376+
# rn=3 row's value. The guard keeps the normal plan, so LEAD sees the full
377+
# partition and next_val is correct (30 / 25 / 100 for the rn=2 rows).
378+
query IIIII rowsort
379+
SELECT id, pk, val, rn, next_val FROM (
380+
SELECT *,
381+
ROW_NUMBER() OVER (PARTITION BY pk ORDER BY val) as rn,
382+
LEAD(val) OVER (PARTITION BY pk ORDER BY val) as next_val
383+
FROM window_topn_t
384+
) WHERE rn <= 2;
385+
----
386+
1 1 10 1 20
387+
10 3 75 2 100
388+
2 1 20 2 30
389+
5 2 5 1 15
390+
6 2 15 2 25
391+
9 3 50 1 75
392+
393+
# A tie table for sibling-safety correctness: pk=1 has three peers at
394+
# val=5, one 10, one 20 (row_number 1..5; rank 1,1,1,4,5; dense_rank
395+
# 1,1,1,2,3).
396+
statement ok
397+
CREATE TABLE window_topn_sib_t (id INT, pk INT, val INT) AS VALUES
398+
(1, 1, 5),
399+
(2, 1, 5),
400+
(3, 1, 5),
401+
(4, 1, 10),
402+
(5, 1, 20);
403+
404+
# SUM sibling (correctness): fetch=2 but there are three peers at val=5,
405+
# so one peer would be pruned. The guard keeps the normal plan, so the
406+
# RANGE frame sees all three 5s and running_sum = 15. If the rule fired,
407+
# the pruned 2-row input would sum to 10. (id excluded — which two of the
408+
# three tied rows get row_number 1/2 is not deterministic.)
409+
query III rowsort
410+
SELECT pk, val, running_sum FROM (
411+
SELECT *,
412+
ROW_NUMBER() OVER (PARTITION BY pk ORDER BY val) as rn,
413+
SUM(val) OVER (PARTITION BY pk ORDER BY val) as running_sum
414+
FROM window_topn_sib_t
415+
) WHERE rn <= 2;
416+
----
417+
1 5 15
418+
1 5 15
419+
420+
# All three ranking siblings (ROW_NUMBER + RANK + DENSE_RANK) over the same
421+
# window are prune-safe, so the rule DOES fire (filter on rn). Verify the
422+
# rank/dense_rank values stay correct under the optimized plan — including
423+
# the val=10 row where row_number=4, rank=4, dense_rank=2 all differ.
424+
query IIII rowsort
425+
SELECT pk, val, rnk, dr FROM (
426+
SELECT *,
427+
ROW_NUMBER() OVER (PARTITION BY pk ORDER BY val) as rn,
428+
RANK() OVER (PARTITION BY pk ORDER BY val) as rnk,
429+
DENSE_RANK() OVER (PARTITION BY pk ORDER BY val) as dr
430+
FROM window_topn_sib_t
431+
) WHERE rn <= 4;
432+
----
433+
1 10 4 2
434+
1 5 1 1
435+
1 5 1 1
436+
1 5 1 1
437+
438+
statement ok
439+
DROP TABLE window_topn_sib_t;
440+
335441
# Test 15: ROW_NUMBER with DESC ordering — correctness
336442
query III rowsort
337443
SELECT id, pk, val FROM (
@@ -1292,6 +1398,34 @@ SELECT id, pk, val FROM (
12921398
) WHERE dr < 1;
12931399
----
12941400

1401+
# Test DR11b: ORDER BY keys fully covered by the PARTITION BY prefix
1402+
# (`PARTITION BY pk ORDER BY pk`). The deduplicated sort ordering is just
1403+
# `[pk]`, so no order key survives past the partition prefix. The rule
1404+
# must NOT fire — a PartitionedTopKExec built with zero order expressions
1405+
# panics on execution. Every row ties at dense_rank 1, so `dr <= 2` keeps
1406+
# the whole table (returned via the ordinary window + filter plan).
1407+
query III rowsort
1408+
SELECT id, pk, val FROM (
1409+
SELECT *, DENSE_RANK() OVER (PARTITION BY pk ORDER BY pk) as dr FROM window_topn_dense_rank_t
1410+
) WHERE dr <= 2;
1411+
----
1412+
1 1 10
1413+
10 2 25
1414+
11 3 100
1415+
12 3 200
1416+
13 3 200
1417+
14 3 200
1418+
15 3 200
1419+
16 3 300
1420+
2 1 20
1421+
3 1 20
1422+
4 1 20
1423+
5 1 30
1424+
6 1 40
1425+
7 2 5
1426+
8 2 5
1427+
9 2 15
1428+
12951429
statement ok
12961430
DROP TABLE window_topn_dense_rank_t;
12971431

0 commit comments

Comments
 (0)