This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion.git


The following commit(s) were added to refs/heads/main by this push:
     new 6eaca8bfe8 perf: Extend WindowTopN to `dense_rank` (#23869)
6eaca8bfe8 is described below

commit 6eaca8bfe8421faca1413f29f71fcaf0ccaf631a
Author: Subham Singhal <[email protected]>
AuthorDate: Tue Aug 18 03:44:06 2026 +0000

    perf: Extend WindowTopN to `dense_rank` (#23869)
    
    ## Which issue does this PR close?
    
    - Related to #6899. Completes the ROW_NUMBER + RANK + DENSE_RANK trio
    requested by the issue (ROW_NUMBER shipped in #21479; RANK is in flight
    as PR A).
    
      ## Rationale for this change
    
    Naive plan for `Filter(dr <= K) → BoundedWindowAggExec(DENSE_RANK) →
    SortExec(pk, ob) → input` sorts the full input even though only rows at
    the K distinct-smallest ob values per partition are needed. DENSE_RANK
    is stable under tail-pruning, so the rewrite preserves ranks for every
    retained row.
    
      ## What changes are included in this PR?
    
      ## Are these changes tested?
    
      Yes
    
    **h2o bench** — Q24–Q29 added; top-2 on 10M-row `large`, 3-iter avg vs
    `enable_window_topn=false`:
    
    
      | #   | Cardinality      | OFF (ms) | ON (ms) | Δ            |
      |-----|------------------|---------:|--------:|:-------------|
      | Q24 | ~100 parts       |   308.19 |  114.45 | 2.20× faster |
      | Q25 | ~1K, low ties    |   261.24 |  113.85 | 2.13× faster |
      | Q26 | ~1K, heavy ties  |   296.54 |  124.42 | 2.09× faster |
      | Q27 | ~10K, low ties   |   250.83 |  131.85 | 1.70× faster |
      | Q28 | ~10K, heavy ties |   281.00 |  149.00 | 1.76× faster |
      | Q29 | ~100K parts      |   264.32 |  304.37 | 1.40× slower |
    
      ## Are there any user-facing changes?
    
    No breaking changes. Rule fires on DENSE_RANK when
    `datafusion.optimizer.enable_window_topn = true`; flag stays
      `false` by default
---
 .../core/tests/physical_optimizer/window_topn.rs   |  103 +-
 datafusion/physical-optimizer/src/window_topn.rs   |   95 +-
 .../physical-plan/src/sorts/partitioned_topk.rs    |   68 +-
 datafusion/physical-plan/src/topk/mod.rs           | 1165 +++++++++++++++++++-
 datafusion/sqllogictest/test_files/window_topn.slt |  453 ++++++++
 5 files changed, 1828 insertions(+), 56 deletions(-)

diff --git a/datafusion/core/tests/physical_optimizer/window_topn.rs 
b/datafusion/core/tests/physical_optimizer/window_topn.rs
index be78b77b32..a31e7a45e5 100644
--- a/datafusion/core/tests/physical_optimizer/window_topn.rs
+++ b/datafusion/core/tests/physical_optimizer/window_topn.rs
@@ -454,8 +454,12 @@ fn build_ranking_topn_plan(
     Ok(filter)
 }
 
-/// Build a RANK plan with NO ORDER BY: every row ties at rank 1 — degenerate.
-fn build_rank_no_order_by_plan(limit_value: i64) -> Result<Arc<dyn 
ExecutionPlan>> {
+/// Build a RANK / DENSE_RANK plan with NO ORDER BY: every row ties at rank 1 
— degenerate.
+fn build_no_order_by_plan(
+    udwf_factory: fn() -> Arc<datafusion_expr::WindowUDF>,
+    udwf_name: &str,
+    limit_value: i64,
+) -> Result<Arc<dyn ExecutionPlan>> {
     let s = schema();
     let input: Arc<dyn ExecutionPlan> = 
Arc::new(PlaceholderRowExec::new(Arc::clone(&s)));
 
@@ -469,7 +473,7 @@ fn build_rank_no_order_by_plan(limit_value: i64) -> 
Result<Arc<dyn ExecutionPlan
     let partition_by = vec![col("pk", &s)?];
 
     let window_expr = Arc::new(StandardWindowExpr::new(
-        create_udwf_window_expr(&rank_udwf(), &[], &s, "rank".to_string(), 
false)?,
+        create_udwf_window_expr(&udwf_factory(), &[], &s, 
udwf_name.to_string(), false)?,
         &partition_by,
         &[], // empty ORDER BY
         Arc::new(WindowFrame::new_bounds(
@@ -486,7 +490,7 @@ fn build_rank_no_order_by_plan(limit_value: i64) -> 
Result<Arc<dyn ExecutionPlan
         true,
     )?);
 
-    let rk_col = Arc::new(Column::new("rank", 2));
+    let rk_col = Arc::new(Column::new(udwf_name, 2));
     let limit_lit = lit(ScalarValue::UInt64(Some(limit_value as u64)));
     let predicate = Arc::new(BinaryExpr::new(rk_col, Operator::LtEq, 
limit_lit));
     let filter: Arc<dyn ExecutionPlan> =
@@ -548,7 +552,7 @@ fn rank_no_order_by_no_change() -> Result<()> {
     // Without ORDER BY, every row ties at rank 1 — the optimization is
     // degenerate (entire input would be retained, ties storage unbounded).
     // The rule must skip.
-    let plan = build_rank_no_order_by_plan(3)?;
+    let plan = build_no_order_by_plan(rank_udwf, "rank", 3)?;
     let before = plan_str(plan.as_ref());
     let optimized = optimize(plan)?;
     let after = plan_str(optimized.as_ref());
@@ -559,17 +563,98 @@ fn rank_no_order_by_no_change() -> Result<()> {
     Ok(())
 }
 
+// ----------------------------------------------------------------------
+// DENSE_RANK rule tests
+// ----------------------------------------------------------------------
+
 #[test]
-fn dense_rank_no_change() -> Result<()> {
-    // DENSE_RANK is not yet supported by the rule. The plan must pass
-    // through unchanged.
+fn basic_dense_rank_dr_lteq_3() -> Result<()> {
     let plan = build_ranking_topn_plan(dense_rank_udwf, "dense_rank", 3, 
Operator::LtEq)?;
+    let optimized = optimize(plan)?;
+    assert_snapshot!(plan_str(optimized.as_ref()), @r#"
+    BoundedWindowAggExec: wdw=[dense_rank: Field { "dense_rank": UInt64 }, 
frame: ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW], mode=[Sorted]
+      PartitionedTopKExec: fn=dense_rank, fetch=3, partition=[pk@0], 
order=[val@1 ASC]
+        PlaceholderRowExec
+    "#);
+    Ok(())
+}
+
+#[test]
+fn dense_rank_dr_lt_4_becomes_fetch_3() -> Result<()> {
+    let plan = build_ranking_topn_plan(dense_rank_udwf, "dense_rank", 4, 
Operator::Lt)?;
+    let optimized = optimize(plan)?;
+    assert_snapshot!(plan_str(optimized.as_ref()), @r#"
+    BoundedWindowAggExec: wdw=[dense_rank: Field { "dense_rank": UInt64 }, 
frame: ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW], mode=[Sorted]
+      PartitionedTopKExec: fn=dense_rank, fetch=3, partition=[pk@0], 
order=[val@1 ASC]
+        PlaceholderRowExec
+    "#);
+    Ok(())
+}
+
+#[test]
+fn dense_rank_flipped_3_gteq_dr() -> Result<()> {
+    let plan = build_ranking_topn_plan(dense_rank_udwf, "dense_rank", 3, 
Operator::GtEq)?;
+    let optimized = optimize(plan)?;
+    assert_snapshot!(plan_str(optimized.as_ref()), @r#"
+    BoundedWindowAggExec: wdw=[dense_rank: Field { "dense_rank": UInt64 }, 
frame: ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW], mode=[Sorted]
+      PartitionedTopKExec: fn=dense_rank, fetch=3, partition=[pk@0], 
order=[val@1 ASC]
+        PlaceholderRowExec
+    "#);
+    Ok(())
+}
+
+#[test]
+fn dense_rank_flipped_4_gt_dr_becomes_fetch_3() -> Result<()> {
+    let plan = build_ranking_topn_plan(dense_rank_udwf, "dense_rank", 4, 
Operator::Gt)?;
+    let optimized = optimize(plan)?;
+    assert_snapshot!(plan_str(optimized.as_ref()), @r#"
+    BoundedWindowAggExec: wdw=[dense_rank: Field { "dense_rank": UInt64 }, 
frame: ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW], mode=[Sorted]
+      PartitionedTopKExec: fn=dense_rank, fetch=3, partition=[pk@0], 
order=[val@1 ASC]
+        PlaceholderRowExec
+    "#);
+    Ok(())
+}
+
+#[test]
+fn dense_rank_no_order_by_no_change() -> Result<()> {
+    // Without ORDER BY, every row ties at dense_rank 1 — the optimization
+    // is degenerate (entire input would be retained). The rule must skip.
+    let plan = build_no_order_by_plan(dense_rank_udwf, "dense_rank", 3)?;
     let before = plan_str(plan.as_ref());
     let optimized = optimize(plan)?;
     let after = plan_str(optimized.as_ref());
     assert_eq!(
         before, after,
-        "DENSE_RANK is unsupported and must not be rewritten"
+        "DENSE_RANK with empty ORDER BY must not be rewritten"
     );
     Ok(())
 }
+
+// ----------------------------------------------------------------------
+// Shared guard: `fn < 1` keeps nothing
+// ----------------------------------------------------------------------
+
+#[test]
+fn predicate_lt_1_no_change() -> Result<()> {
+    // `fn < 1` (and the flipped `1 > fn`) yields limit_n = 0. Since
+    // ROW_NUMBER / RANK / DENSE_RANK are always >= 1, the predicate keeps
+    // nothing and the rule must skip — a fetch=0 PartitionedTopK* would
+    // otherwise panic on its `k > 0` assertion at execution time.
+    type UdwfFactory = fn() -> Arc<datafusion_expr::WindowUDF>;
+    let cases: [(UdwfFactory, &str); 3] = [
+        (row_number_udwf, "row_number"),
+        (rank_udwf, "rank"),
+        (dense_rank_udwf, "dense_rank"),
+    ];
+    for (factory, name) in cases {
+        let plan = build_ranking_topn_plan(factory, name, 1, Operator::Lt)?;
+        let before = plan_str(plan.as_ref());
+        let optimized = optimize(plan)?;
+        let after = plan_str(optimized.as_ref());
+        assert_eq!(
+            before, after,
+            "`{name} < 1` (limit 0) must not be rewritten"
+        );
+    }
+    Ok(())
+}
diff --git a/datafusion/physical-optimizer/src/window_topn.rs 
b/datafusion/physical-optimizer/src/window_topn.rs
index aa46a5a9a7..758ad66de3 100644
--- a/datafusion/physical-optimizer/src/window_topn.rs
+++ b/datafusion/physical-optimizer/src/window_topn.rs
@@ -26,7 +26,7 @@
 //! ) WHERE rn <= K;
 //! ```
 //!
-//! or with `RANK()` in place of `ROW_NUMBER()`:
+//! or with `RANK()` / `DENSE_RANK()` in place of `ROW_NUMBER()`:
 //!
 //! ```sql
 //! SELECT * FROM (
@@ -40,8 +40,8 @@
 //! `FilterExec` and inserting `PartitionedTopKExec` under the window.
 //!
 //! The appropriate [`WindowFnKind`] is forwarded to `PartitionedTopKExec`.
-//! RANK requires a non-empty `ORDER BY` clause (otherwise all rows tie at
-//! rank 1 and the optimization is degenerate).
+//! `RANK` and `DENSE_RANK` require a non-empty `ORDER BY` clause (otherwise
+//! all rows tie at rank 1 and the optimization is degenerate).
 //!
 //! See [`PartitionedTopKExec`] for details on the replacement operator.
 //!
@@ -69,9 +69,9 @@ use datafusion_physical_plan::sorts::partitioned_topk::{
 };
 use datafusion_physical_plan::windows::{BoundedWindowAggExec, WindowUDFExpr};
 
-/// Physical optimizer rule that converts per-partition `ROW_NUMBER` and
-/// `RANK` top-K queries into a more efficient plan using
-/// [`PartitionedTopKExec`].
+/// Physical optimizer rule that converts per-partition `ROW_NUMBER`,
+/// `RANK`, and `DENSE_RANK` top-K queries into a more efficient plan
+/// using [`PartitionedTopKExec`].
 ///
 /// # Pattern Detected
 ///
@@ -86,12 +86,14 @@ use 
datafusion_physical_plan::windows::{BoundedWindowAggExec, WindowUDFExpr};
 /// ```text
 /// [optional ProjectionExec]
 ///   BoundedWindowAggExec(<ranking fn> PARTITION BY ... ORDER BY ...)
-///     PartitionedTopKExec(fn=<row_number|rank>, partition_keys, order_keys, 
fetch=K)
+///     PartitionedTopKExec(fn=<row_number|rank|dense_rank>, partition_keys, 
order_keys, fetch=K)
 /// ```
 ///
 /// The `FilterExec` is removed entirely. The child of `BoundedWindowAggExec` 
is now
-/// `PartitionedTopKExec`, which maintains a per-partition top-K heap (and,
-/// for `RANK`, a sibling ties `Vec`) instead of sorting the whole dataset.
+/// `PartitionedTopKExec`, which maintains per-partition top-K state (a
+/// heap for `ROW_NUMBER`, a heap plus boundary ties for `RANK`, a
+/// K-bounded distinct-ob map for `DENSE_RANK`) instead of sorting the
+/// whole dataset.
 ///
 /// # Supported Predicates
 ///
@@ -105,12 +107,19 @@ use 
datafusion_physical_plan::windows::{BoundedWindowAggExec, WindowUDFExpr};
 /// All of the following must be true:
 /// - Config flag `enable_window_topn` is `true`
 /// - The plan matches `FilterExec → [ProjectionExec] → BoundedWindowAggExec`
-/// - The window function is `ROW_NUMBER` or `RANK` (not `DENSE_RANK`)
+/// - The window function is `ROW_NUMBER`, `RANK`, or `DENSE_RANK`
+/// - Every window expression in the `BoundedWindowAggExec` is `ROW_NUMBER`,
+///   `RANK`, or `DENSE_RANK` over the same `PARTITION BY` / `ORDER BY`. A
+///   sibling that reads pruned rows (e.g. `LEAD`, or an aggregate whose
+///   frame is not strictly backward-looking) would be computed over the
+///   pruned input and give wrong results.
 /// - The window function has a `PARTITION BY` clause (global top-K is
 ///   already handled by `SortExec` with `fetch`)
-/// - For `RANK`: a non-empty `ORDER BY` clause (otherwise all rows tie
-///   at rank 1 — the optimization is useless and the boundary-tie storage
-///   would be unbounded)
+/// - At least one `ORDER BY` key survives past the `PARTITION BY` prefix
+///   (so the operator has a non-empty ORDER BY). This rejects both a
+///   missing `ORDER BY` and one fully covered by the partition prefix
+///   such as `PARTITION BY pk ORDER BY pk`; for `RANK` / `DENSE_RANK`
+///   such orderings also make every row tie at rank 1 (degenerate).
 /// - The filter predicate compares the window output column to an integer
 ///   literal using `<=`, `<`, `>=`, or `>`
 ///
@@ -142,10 +151,10 @@ impl WindowTopN {
         let (col_idx, limit_n) = extract_window_limit(filter.predicate())?;
 
         // A predicate such as `rn < 1` (or the flipped `1 > rn`) yields a 
fetch of
-        // 0. `ROW_NUMBER`/`RANK` are always >= 1, so no row can satisfy it 
and the
-        // correct result is empty. `PartitionedTopKExec` requires `k > 0` and 
would
-        // panic on `k = 0`, so bail out here and let the regular `FilterExec` 
produce
-        // the (empty) result instead of rewriting.
+        // 0. `ROW_NUMBER`/`RANK`/`DENSE_RANK` are always >= 1, so no row can 
satisfy
+        // it and the correct result is empty. `PartitionedTopKExec` requires 
`k > 0`
+        // and would panic on `k = 0`, so bail out here and let the regular
+        // `FilterExec` produce the (empty) result instead of rewriting.
         if limit_n == 0 {
             return None;
         }
@@ -167,6 +176,28 @@ impl WindowTopN {
         }
         let fn_kind = supported_window_fn(&window_exprs[window_expr_idx])?;
 
+        // Tail-pruning drops the rows that rank after the retained top-K,
+        // and every window expression in this `BoundedWindowAggExec` is
+        // then evaluated over the *pruned* input. The rewrite is only valid
+        // if each expression's value for a *retained* row is unaffected by
+        // the dropped rows. ROW_NUMBER / RANK / DENSE_RANK over the same
+        // PARTITION BY / ORDER BY satisfy this — each depends only on rows
+        // at or before the current row in that order, all of which are
+        // retained. A sibling like `LEAD(x)` reads following (pruned) rows,
+        // so at the retained boundary it would resolve to a dropped row and
+        // give a wrong result. Bail out unless every window expression is a
+        // supported ranking function sharing the matched expression's
+        // partition/order keys.
+        let matched_expr = &window_exprs[window_expr_idx];
+        let all_prune_safe = window_exprs.iter().all(|e| {
+            supported_window_fn(e).is_some()
+                && e.partition_by() == matched_expr.partition_by()
+                && e.order_by() == matched_expr.order_by()
+        });
+        if !all_prune_safe {
+            return None;
+        }
+
         // Step 5: Validate PARTITION BY / ORDER BY and collect sort keys from 
the window expr
         let partition_by = window_exprs[window_expr_idx].partition_by();
         let partition_prefix_len = partition_by.len();
@@ -177,21 +208,31 @@ impl WindowTopN {
             return None;
         }
 
-        // For RANK: an empty ORDER BY makes every row tie at rank 1 —
-        // the optimization is degenerate (we'd retain the entire input)
-        // and tie storage would be unbounded.
-        let order_by = window_exprs[window_expr_idx].order_by();
-        if matches!(fn_kind, WindowFnKind::Rank) && order_by.is_empty() {
-            return None;
-        }
-
         // Step 6: Build PartitionedTopKExec from the window's partition/order 
keys
+        let order_by = window_exprs[window_expr_idx].order_by();
         let expr_iterator = partition_by
             .iter()
             .map(|e| PhysicalSortExpr::new_default(Arc::clone(e)))
             .chain(order_by.iter().cloned());
         let expr = LexOrdering::new(expr_iterator)?;
 
+        // `PartitionedTopKExec` derives its ORDER BY keys from the ordering
+        // *beyond* the partition prefix (`expr[partition_prefix_len..]`).
+        // That slice is empty in two cases:
+        //   * no ORDER BY at all (e.g. `ROW_NUMBER() OVER (PARTITION BY pk)`);
+        //   * ORDER BY keys fully covered by the partition prefix (e.g.
+        //     `DENSE_RANK() OVER (PARTITION BY pk ORDER BY pk)`, whose
+        //     deduplicated ordering is just `[pk]`).
+        // With zero order keys the operator panics on execution (it
+        // requires at least one), and for RANK / DENSE_RANK every row
+        // would tie at rank 1 (a degenerate, unbounded retained set).
+        // `order_by()` alone does not catch the second case — it reports
+        // `[pk]` even though no order key survives past the partition
+        // prefix — so guard on the effective order-key count instead.
+        if expr.len() <= partition_prefix_len {
+            return None;
+        }
+
         let partitioned_topk = PartitionedTopKExec::try_new(
             Arc::clone(window_exec_typed.input()),
             expr,
@@ -325,7 +366,8 @@ fn scalar_to_usize(value: &ScalarValue) -> Option<usize> {
 /// the UDF name. Returns:
 /// - `Some(WindowFnKind::RowNumber)` for `"row_number"`
 /// - `Some(WindowFnKind::Rank)` for `"rank"`
-/// - `None` for everything else (e.g. `dense_rank`)
+/// - `Some(WindowFnKind::DenseRank)` for `"dense_rank"`
+/// - `None` for everything else
 fn supported_window_fn(
     expr: &Arc<dyn datafusion_physical_expr::window::WindowExpr>,
 ) -> Option<WindowFnKind> {
@@ -335,6 +377,7 @@ fn supported_window_fn(
     match udf.fun().name() {
         "row_number" => Some(WindowFnKind::RowNumber),
         "rank" => Some(WindowFnKind::Rank),
+        "dense_rank" => Some(WindowFnKind::DenseRank),
         _ => None,
     }
 }
diff --git a/datafusion/physical-plan/src/sorts/partitioned_topk.rs 
b/datafusion/physical-plan/src/sorts/partitioned_topk.rs
index 41ccfab683..cd8bd340d2 100644
--- a/datafusion/physical-plan/src/sorts/partitioned_topk.rs
+++ b/datafusion/physical-plan/src/sorts/partitioned_topk.rs
@@ -24,9 +24,9 @@
 //! ```
 //!
 //! Instead of sorting the entire dataset, this operator delegates to a
-//! per-partition heap-of-K implementation (one variant for `ROW_NUMBER`
-//! and a sibling variant for `RANK`), both of which maintain one heap per
-//! distinct partition key while sharing a single [`arrow::row::RowConverter`],
+//! per-partition top-K implementation — one variant each for `ROW_NUMBER`,
+//! `RANK`, and `DENSE_RANK` — all of which keep per-partition state while
+//! sharing a single [`arrow::row::RowConverter`],
 //! 
[`MemoryReservation`](datafusion_execution::memory_pool::MemoryReservation),
 //! and metrics set across all partitions, and emit only the top-K rows
 //! per partition in sorted order `(partition_keys, order_keys)`.
@@ -47,7 +47,9 @@ use futures::TryStreamExt;
 
 use crate::execution_plan::{Boundedness, EmissionType};
 use crate::metrics::ExecutionPlanMetricsSet;
-use crate::topk::{PartitionedTopK, PartitionedTopKRank, build_sort_fields};
+use crate::topk::{
+    PartitionedTopK, PartitionedTopKDenseRank, PartitionedTopKRank, 
build_sort_fields,
+};
 use crate::{ChildrenPropertiesMode, ReplaceChildrenOptions};
 use crate::{
     DisplayAs, DisplayFormatType, Distribution, ExecutionPlan, 
ExecutionPlanProperties,
@@ -61,12 +63,19 @@ use crate::{
 /// - [`Rank`](Self::Rank): K rows plus any rows tied at the boundary
 ///   ORDER BY value (RANK semantics — `WHERE rk <= K` may keep more
 ///   than K rows when ties straddle the boundary).
+/// - [`DenseRank`](Self::DenseRank): every row whose ORDER BY value is
+///   among the K distinct-smallest ORDER BY values in the partition
+///   (DENSE_RANK semantics — total kept rows is unbounded in
+///   rows-per-distinct-value).
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum WindowFnKind {
     /// `ROW_NUMBER()` — keep exactly K rows per partition.
     RowNumber,
     /// `RANK()` — keep K rows plus any rows tied at the boundary.
     Rank,
+    /// `DENSE_RANK()` — keep every row whose ob value is among the K
+    /// distinct-smallest ob values seen in the partition.
+    DenseRank,
 }
 
 /// Per-partition Top-K operator for window function queries.
@@ -110,9 +119,10 @@ pub enum WindowFnKind {
 /// ```
 ///
 /// Instead of sorting the entire dataset, this operator reads unsorted input
-/// and delegates to a per-partition heap-of-K implementation 
(`PartitionedTopK`
-/// for `ROW_NUMBER` and `PartitionedTopKRank` for `RANK`), each maintaining
-/// one heap per distinct partition key while sharing a single
+/// and delegates to a per-partition top-K implementation (`PartitionedTopK`
+/// for `ROW_NUMBER`, `PartitionedTopKRank` for `RANK`, and
+/// `PartitionedTopKDenseRank` for `DENSE_RANK`), each maintaining
+/// per-partition state while sharing a single
 /// [`arrow::row::RowConverter`] /
 /// [`MemoryReservation`](datafusion_execution::memory_pool::MemoryReservation)
 /// across all partitions, and emits only the top-K rows per partition in
@@ -164,11 +174,12 @@ pub enum WindowFnKind {
 ///
 /// # Limitations
 ///
-/// - Only activated when the window function is `ROW_NUMBER` or `RANK` with
-///   a `PARTITION BY` clause. `RANK` additionally requires a non-empty
-///   `ORDER BY` (with an empty `ORDER BY`, every row ties at rank 1 and the
-///   heap-of-K rewrite doesn't apply). Global top-K (no `PARTITION BY`) is
-///   already handled efficiently by `SortExec` with `fetch`.
+/// - Only activated when the window function is `ROW_NUMBER`, `RANK`, or
+///   `DENSE_RANK` with a `PARTITION BY` clause. `RANK` and `DENSE_RANK`
+///   additionally require a non-empty `ORDER BY` (with an empty `ORDER BY`
+///   every row ties at rank 1 and the rewrite doesn't apply). Global top-K
+///   (no `PARTITION BY`) is already handled efficiently by `SortExec` with
+///   `fetch`.
 /// - For very high cardinality partition keys (millions of distinct values),
 ///   both memory usage and runtime overhead can become significant. In such
 ///   cases, the sort-based plan is more robust. Therefore, this optimization
@@ -212,7 +223,8 @@ impl PartitionedTopKExec {
     ///   that form the partition key. Must be >= 1.
     /// * `fetch` - Maximum rows to retain per partition (the K in "top-K").
     /// * `fn_kind` - Which ranking window function this operator optimizes
-    ///   ([`WindowFnKind::RowNumber`] or [`WindowFnKind::Rank`]).
+    ///   ([`WindowFnKind::RowNumber`], [`WindowFnKind::Rank`], or
+    ///   [`WindowFnKind::DenseRank`]).
     ///
     /// # Example
     ///
@@ -297,6 +309,7 @@ impl DisplayAs for PartitionedTopKExec {
         let fn_label = match self.fn_kind {
             WindowFnKind::RowNumber => "row_number",
             WindowFnKind::Rank => "rank",
+            WindowFnKind::DenseRank => "dense_rank",
         };
         match t {
             DisplayFormatType::Default | DisplayFormatType::Verbose => {
@@ -453,9 +466,9 @@ impl ExecutionPlan for PartitionedTopKExec {
 }
 
 /// Read all input, feed each batch into a per-partition top-K state
-/// (either [`PartitionedTopK`] for `ROW_NUMBER` or
-/// [`PartitionedTopKRank`] for `RANK`), then emit results ordered by
-/// `(partition_keys, order_keys)`.
+/// ([`PartitionedTopK`] for `ROW_NUMBER`, [`PartitionedTopKRank`] for
+/// `RANK`, or [`PartitionedTopKDenseRank`] for `DENSE_RANK`), then emit
+/// results ordered by `(partition_keys, order_keys)`.
 ///
 /// # Phases
 ///
@@ -465,10 +478,11 @@ impl ExecutionPlan for PartitionedTopKExec {
 ///    `TopKMetrics` are shared across all distinct partition keys for
 ///    this operator instance.
 ///
-/// 2. **Emission** — `emit` drains all per-partition heaps in sorted
+/// 2. **Emission** — `emit` drains all per-partition state in sorted
 ///    partition-key order, returning a coalesced batch stream. For
 ///    `RANK`, boundary-tied rows are materialized and emitted after
-///    each partition's heap rows.
+///    each partition's heap rows. For `DENSE_RANK`, rows are emitted
+///    from a K-bounded map of distinct ob keys, sorted ascending.
 ///
 /// # Cost
 ///
@@ -526,5 +540,23 @@ async fn do_partitioned_topk(
             drop(input);
             state.emit()
         }
+        WindowFnKind::DenseRank => {
+            let mut state = PartitionedTopKDenseRank::try_new(
+                partition_id,
+                schema,
+                partition_exprs,
+                partition_sort_fields,
+                order_expr,
+                fetch,
+                batch_size,
+                &runtime,
+                &metrics_set,
+            )?;
+            while let Some(batch) = input.next().await {
+                state.insert_batch(&batch?)?;
+            }
+            drop(input);
+            state.emit()
+        }
     }
 }
diff --git a/datafusion/physical-plan/src/topk/mod.rs 
b/datafusion/physical-plan/src/topk/mod.rs
index 1e3efff36b..1d02397b92 100644
--- a/datafusion/physical-plan/src/topk/mod.rs
+++ b/datafusion/physical-plan/src/topk/mod.rs
@@ -1811,10 +1811,454 @@ impl PartitionedTopKRank {
     }
 }
 
+/// A run of rows from a single source [`RecordBatch`] sharing one
+/// distinct ORDER BY value. Materialized at emit time via
+/// [`take_record_batch`].
+///
+/// The batch is referenced by `batch_id` rather than held directly, so the
+/// operator-scoped [`RecordBatchStore`] can charge each distinct source
+/// batch once however many entries reference it. Holding a batch per entry
+/// and charging its bytes per entry would inflate the reservation by a
+/// factor of (partitions × K), since a single batch contributes an entry to
+/// every partition and ob group it touches.
+#[derive(Debug)]
+struct GroupEntry {
+    /// Indices into the batch identified by `batch_id`. Always non-empty
+    /// by construction.
+    row_indices: Vec<u32>,
+    /// Key into `PartitionedTopKDenseRank::store`.
+    batch_id: u32,
+}
+
+/// Per-partition state for `DENSE_RANK()` semantics.
+///
+/// A `HashMap<Vec<u8>, Vec<GroupEntry>>` keyed by the row-encoded ORDER
+/// BY bytes, capped at `k` distinct keys. Each key's `Vec<GroupEntry>`
+/// holds every row seen at that ob value, one entry per contributing
+/// source `RecordBatch`.
+#[derive(Default)]
+struct DenseRankPartitionState {
+    groups: HashMap<Vec<u8>, Vec<GroupEntry>>,
+    /// The same keys as `groups`, in a max-heap: the admission boundary
+    /// (the largest tracked ob value) is an O(1) `peek()` check, and
+    /// admission / removal are O(log K).
+    ///
+    /// INVARIANT: `keys` and `groups.keys()` hold the same set. Every
+    /// insertion into / removal from `groups` must mirror into `keys`.
+    keys: BinaryHeap<Vec<u8>>,
+}
+
+impl DenseRankPartitionState {
+    fn size(&self) -> usize {
+        let table_overhead = self.groups.capacity()
+            * (size_of::<Vec<u8>>() + size_of::<Vec<GroupEntry>>());
+        let contents: usize = self
+            .groups
+            .iter()
+            .map(|(key, entries)| {
+                key.capacity()
+                    + entries.capacity() * size_of::<GroupEntry>()
+                    + entries
+                        .iter()
+                        .map(|e| e.row_indices.capacity() * size_of::<u32>())
+                        .sum::<usize>()
+            })
+            .sum();
+        // `keys` duplicates every key's bytes; charge for them plus the
+        // heap's backing Vec (one `Vec<u8>` slot per reserved element).
+        let keys_overhead: usize = self.keys.capacity() * size_of::<Vec<u8>>()
+            + self.keys.iter().map(|k| k.capacity()).sum::<usize>();
+        table_overhead + contents + keys_overhead
+    }
+}
+
+/// Sibling to [`PartitionedTopK`] / [`PartitionedTopKRank`] implementing
+/// `DENSE_RANK()` semantics.
+///
+/// Per partition, retains every row whose ORDER BY value is among the K
+/// distinct-smallest ob values seen for that partition. The total row
+/// count kept per partition is unbounded in `rows_per_distinct_value`
+/// (unlike `RANK`, which is bounded above by K + boundary ties).
+///
+/// Like [`PartitionedTopK`], the [`RowConverter`], [`MemoryReservation`],
+/// scratch [`Rows`] buffer, and [`TopKMetrics`] are shared across all
+/// partitions for this operator instance. So is the
+/// [`RecordBatchStore`]: retained rows are held as `(batch_id, indices)`
+/// so each source batch is charged once for the whole operator, however
+/// many partitions and ob groups reference it.
+///
+/// # Algorithm (per batch)
+///
+/// Evaluate + encode partition-by and order-by columns once, then group
+/// the batch's row indices by partition key. For each partition, bucket
+/// that partition's rows by distinct ob value (a within-call
+/// accumulation), then merge each bucket into the partition state. Every
+/// bucket is built from the current batch's rows, so each `GroupEntry` is
+/// pinned to the batch its `row_indices` point into.
+///
+/// For each partition, for each distinct `ob_key` run in this batch:
+/// - `ob_key` already in `state.groups` → push this batch's run as a
+///   new `GroupEntry` (one entry per contributing batch).
+/// - `ob_key` new, `state.groups.len() < k` → insert the run as a new
+///   group.
+/// - `ob_key` new, `state.groups.len() == k` → the largest tracked ob
+///   value is the admission boundary, read from the `state.keys` max-heap
+///   in O(1):
+///   - `ob_key < max` → remove the max key (evict the entire max-key
+///     group — up to many rows) and insert the run. The evicted group's
+///     row count is added to the `row_replacements` metric.
+///   - `ob_key >= max` → drop the whole run; no map mutation.
+pub(crate) struct PartitionedTopKDenseRank {
+    schema: SchemaRef,
+    metrics: TopKMetrics,
+    reservation: MemoryReservation,
+    /// ORDER BY expressions (excludes PARTITION BY).
+    expr: LexOrdering,
+    /// Encoder for ORDER BY columns. Reused across partitions.
+    row_converter: RowConverter,
+    /// Scratch row buffer reused across `insert_batch` calls.
+    scratch_rows: Rows,
+    /// PARTITION BY expressions.
+    partition_exprs: Vec<Arc<dyn PhysicalExpr>>,
+    /// Encoder for the partition key.
+    partition_converter: RowConverter,
+    /// Scratch row buffer for partition-key encoding. Reused across
+    /// `insert_batch` calls (cleared + appended each batch).
+    partition_scratch_rows: Rows,
+    /// One state per distinct partition key seen so far. Keyed by the
+    /// row-encoded PARTITION BY bytes (byte-comparable encoding, so the
+    /// `Vec<u8>` hashes, compares, and sorts identically to an
+    /// `OwnedRow`) which lets `insert_batch` look partitions up with
+    /// `entry_ref` — allocating a key only on first sight of a partition
+    /// rather than once per row.
+    states: HashMap<Vec<u8>, DenseRankPartitionState>,
+    /// Scratch map reused across `insert_batch` calls to group a batch's
+    /// row indices by partition key. Drained (not reallocated) each batch
+    /// so its backing table is allocated once, not per batch.
+    partition_groups: HashMap<Vec<u8>, Vec<u32>>,
+    /// Scratch map reused across partitions within a batch to bucket a
+    /// partition's rows by distinct ORDER BY value. Drained (not
+    /// reallocated) per partition so its backing table is allocated once,
+    /// not once per distinct partition key.
+    ob_runs: HashMap<Vec<u8>, Vec<u32>>,
+    /// Source batches referenced by the retained `GroupEntry`s, held once
+    /// for the whole operator with a use count per batch. This is what
+    /// keeps the reservation proportional to the batches actually pinned
+    /// rather than to the number of entries pointing at them.
+    store: RecordBatchStore,
+    k: usize,
+    batch_size: usize,
+}
+
+impl PartitionedTopKDenseRank {
+    #[expect(clippy::too_many_arguments)]
+    pub(crate) fn try_new(
+        partition_id: usize,
+        schema: SchemaRef,
+        partition_exprs: Vec<Arc<dyn PhysicalExpr>>,
+        partition_sort_fields: Vec<SortField>,
+        order_expr: LexOrdering,
+        k: usize,
+        batch_size: usize,
+        runtime: &Arc<RuntimeEnv>,
+        metrics: &ExecutionPlanMetricsSet,
+    ) -> Result<Self> {
+        assert!(k > 0, "PartitionedTopKDenseRank requires k > 0");
+        let reservation =
+            
MemoryConsumer::new(format!("PartitionedTopKDenseRank[{partition_id}]"))
+                .register(&runtime.memory_pool);
+
+        let order_sort_fields = build_sort_fields(&order_expr, &schema)?;
+        let row_converter = RowConverter::new(order_sort_fields)?;
+        let scratch_rows =
+            row_converter.empty_rows(batch_size, ESTIMATED_BYTES_PER_ROW * 
batch_size);
+
+        let partition_converter = RowConverter::new(partition_sort_fields)?;
+        let partition_scratch_rows = partition_converter
+            .empty_rows(batch_size, ESTIMATED_BYTES_PER_ROW * batch_size);
+
+        Ok(Self {
+            schema,
+            metrics: TopKMetrics::new(metrics, partition_id),
+            reservation,
+            expr: order_expr,
+            row_converter,
+            scratch_rows,
+            partition_exprs,
+            partition_converter,
+            partition_scratch_rows,
+            states: HashMap::new(),
+            partition_groups: HashMap::new(),
+            ob_runs: HashMap::new(),
+            store: RecordBatchStore::new(),
+            k,
+            batch_size,
+        })
+    }
+
+    /// Encode PARTITION BY and ORDER BY columns once, demultiplex the
+    /// batch's rows by partition key, then per partition bucket the rows
+    /// by distinct ob value and merge each bucket into the partition
+    /// state as one [`GroupEntry`].
+    pub(crate) fn insert_batch(&mut self, batch: &RecordBatch) -> Result<()> {
+        let baseline = self.metrics.baseline.clone();
+        let _timer = baseline.elapsed_compute().timer();
+
+        let num_rows = batch.num_rows();
+        if num_rows == 0 {
+            return Ok(());
+        }
+
+        // Register this batch with the store up front. `uses` is bumped
+        // once per `GroupEntry` created below but lives on this local
+        // entry, so the store sees a single insert per batch rather than
+        // one per group — and `insert` drops batches that retained
+        // nothing without ever charging for them.
+        let mut batch_entry = self.store.register(batch.clone());
+        let batch_id = batch_entry.id;
+
+        // 1. Encode partition columns.
+        let pk_arrays: Vec<ArrayRef> = self
+            .partition_exprs
+            .iter()
+            .map(|e| e.evaluate(batch).and_then(|v| v.into_array(num_rows)))
+            .collect::<Result<_>>()?;
+        self.partition_scratch_rows.clear();
+        self.partition_converter
+            .append(&mut self.partition_scratch_rows, &pk_arrays)?;
+
+        // 2. Group this batch's row indices by partition key.
+        //    `partition_groups` is a reused scratch map: taken out here
+        //    and drained below, so its backing table is allocated once
+        //    for the operator, not once per batch. `entry_ref` owns the
+        //    key only on Vacant, so it allocates one `Vec<u8>` per
+        //    distinct partition rather than one per row.
+        let mut groups = std::mem::take(&mut self.partition_groups);
+        groups.clear();
+        {
+            let pk_rows = &self.partition_scratch_rows;
+            for i in 0..num_rows {
+                groups
+                    .entry_ref(pk_rows.row(i).as_ref())
+                    .or_default()
+                    .push(i as u32);
+            }
+        }
+
+        // 3. Evaluate ORDER BY columns and encode ONCE.
+        let ob_arrays: Vec<ArrayRef> = self
+            .expr
+            .iter()
+            .map(|e| e.expr.evaluate(batch).and_then(|v| 
v.into_array(num_rows)))
+            .collect::<Result<_>>()?;
+        self.scratch_rows.clear();
+        self.row_converter
+            .append(&mut self.scratch_rows, &ob_arrays)?;
+
+        let k = self.k;
+        let mut replacements: usize = 0;
+
+        // 4. Per-partition: bucket this batch's rows by distinct ob value
+        //    (within-call accumulation), then merge each bucket into the
+        //    partition state as a single `GroupEntry`.
+        for (pk, indices) in groups.drain() {
+            let state = self
+                .states
+                .entry(pk)
+                .or_insert_with(DenseRankPartitionState::default);
+
+            // Bucket by ob key. `ob_runs` is a reused scratch map (taken
+            // out and drained below) so its backing table is allocated
+            // once, not once per distinct partition key. `entry_ref` owns
+            // the key only on Vacant, so repeated rows of the same ob
+            // value don't re-allocate.
+            let mut runs = std::mem::take(&mut self.ob_runs);
+            runs.clear();
+            for &orig_idx in &indices {
+                let ob_row = self.scratch_rows.row(orig_idx as usize);
+                runs.entry_ref(ob_row.as_ref()).or_default().push(orig_idx);
+            }
+
+            for (ob_key, run_indices) in runs.drain() {
+                // Case A: ob already tracked — push this batch's run as a
+                // new `GroupEntry` (one entry per contributing batch).
+                if let Some(entries) = state.groups.get_mut(&ob_key) {
+                    batch_entry.uses += 1;
+                    entries.push(GroupEntry {
+                        row_indices: run_indices,
+                        batch_id,
+                    });
+                    continue;
+                }
+
+                // Case B: new ob, room available.
+                if state.groups.len() < k {
+                    batch_entry.uses += 1;
+                    state.keys.push(ob_key.clone());
+                    state.groups.insert(
+                        ob_key,
+                        vec![GroupEntry {
+                            row_indices: run_indices,
+                            batch_id,
+                        }],
+                    );
+                    continue;
+                }
+
+                // Case C: new ob, at K distinct keys. The largest tracked
+                // ob value is the admission boundary.
+                let max_key = state.keys.peek().expect("state.groups has k >= 
1 keys");
+                if ob_key.as_slice() < max_key.as_slice() {
+                    // Evict the entire max-key group, from both the map
+                    // and its ordered mirror.
+                    let evicted_key = state.keys.pop().expect("max key 
present");
+                    let evicted = state
+                        .groups
+                        .remove(&evicted_key)
+                        .expect("keys mirrors groups");
+                    for e in &evicted {
+                        replacements += e.row_indices.len();
+                        if e.batch_id == batch_id {
+                            // Admitted earlier in this same call, so the
+                            // store has not seen `batch_entry` yet — drop
+                            // the pending use rather than calling `unuse`,
+                            // which panics on an unregistered id.
+                            batch_entry.uses -= 1;
+                        } else {
+                            self.store.unuse(e.batch_id);
+                        }
+                    }
+                    batch_entry.uses += 1;
+                    state.keys.push(ob_key.clone());
+                    state.groups.insert(
+                        ob_key,
+                        vec![GroupEntry {
+                            row_indices: run_indices,
+                            batch_id,
+                        }],
+                    );
+                }
+                // else: ob >= max — drop the whole run.
+            }
+
+            // Return the drained scratch map (capacity retained) for the
+            // next partition to reuse.
+            self.ob_runs = runs;
+        }
+
+        // Return the drained scratch map (capacity retained) for the next
+        // batch to reuse.
+        self.partition_groups = groups;
+
+        // Charges `batch` once if any group retained rows from it.
+        self.store.insert(batch_entry);
+
+        if replacements > 0 {
+            self.metrics.row_replacements.add(replacements);
+        }
+        self.reservation.try_resize(self.size())?;
+        Ok(())
+    }
+
+    /// Drain all per-partition maps in partition-key order and return
+    /// the rows as a stream of coalesced [`RecordBatch`]es ordered by
+    /// `(partition_keys, order_keys)`. Within a partition the distinct
+    /// ob keys are sorted (byte-comparable encoding == sort order) so
+    /// emitted rows are in ob-sorted order.
+    pub(crate) fn emit(self) -> Result<SendableRecordBatchStream> {
+        let Self {
+            schema,
+            metrics,
+            reservation: _,
+            expr: _,
+            row_converter: _,
+            scratch_rows: _,
+            partition_exprs: _,
+            partition_converter: _,
+            partition_scratch_rows: _,
+            mut states,
+            partition_groups: _,
+            ob_runs: _,
+            store,
+            k: _,
+            batch_size,
+        } = self;
+        let _timer = metrics.baseline.elapsed_compute().timer();
+
+        let mut sorted_pks: Vec<Vec<u8>> = states.keys().cloned().collect();
+        sorted_pks.sort();
+
+        let mut coalescer = BatchCoalescer::new(Arc::clone(&schema), 
batch_size);
+
+        for pk in sorted_pks {
+            let DenseRankPartitionState { groups, keys: _ } =
+                states.remove(&pk).expect("key from states.keys()");
+            // Sort the <= K distinct ob keys so rows emit ascending
+            // (byte-comparable encoding == sort order).
+            let mut sorted_obs: Vec<(Vec<u8>, Vec<GroupEntry>)> =
+                groups.into_iter().collect();
+            sorted_obs.sort_by(|a, b| a.0.cmp(&b.0));
+            for (_ob, entries) in sorted_obs {
+                for entry in entries {
+                    let batch = &store
+                        .get(entry.batch_id)
+                        .expect("retained batch_id present in store")
+                        .batch;
+                    let indices = UInt32Array::from(entry.row_indices);
+                    let sub = take_record_batch(batch, &indices)?;
+                    (&sub).record_output(&metrics.baseline);
+                    coalescer.push_batch(sub)?;
+                }
+            }
+        }
+        coalescer.finish_buffered_batch()?;
+
+        let mut out: Vec<Result<RecordBatch>> = Vec::new();
+        while let Some(b) = coalescer.next_completed_batch() {
+            out.push(Ok(b));
+        }
+
+        Ok(Box::pin(RecordBatchStreamAdapter::new(
+            schema,
+            futures::stream::iter(out),
+        )))
+    }
+
+    /// Total memory currently held, including all per-partition states.
+    fn size(&self) -> usize {
+        // Per partition: the state itself plus the encoded partition key
+        // owned by the map. The key bytes are a heap allocation the table
+        // slot doesn't cover, and with wide or numerous partition keys
+        // they dominate the fixed-size slots.
+        let states_contents: usize = self
+            .states
+            .iter()
+            .map(|(pk, state)| pk.capacity() + state.size())
+            .sum();
+        // `partition_groups` and `ob_runs` are drained, not dropped, so
+        // their backing tables outlive every `insert_batch` call. Both are
+        // empty by the time `size()` runs (drained above), so only the
+        // retained capacity is charged.
+        let scratch_tables = self.partition_groups.capacity()
+            * (size_of::<Vec<u8>>() + size_of::<Vec<u32>>())
+            + self.ob_runs.capacity() * (size_of::<Vec<u8>>() + 
size_of::<Vec<u32>>());
+        size_of::<Self>()
+            + self.row_converter.size()
+            + self.partition_converter.size()
+            + self.scratch_rows.size()
+            + self.partition_scratch_rows.size()
+            + states_contents
+            + self.states.capacity()
+                * (size_of::<Vec<u8>>() + size_of::<DenseRankPartitionState>())
+            + scratch_tables
+            + self.store.size()
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
-    use arrow::array::{BooleanArray, Float64Array, Int32Array};
+    use arrow::array::{BooleanArray, Float64Array, Int32Array, StringArray};
     use arrow::datatypes::{DataType, Field, Schema};
     use arrow_schema::SortOptions;
     use datafusion_common::assert_batches_eq;
@@ -2537,7 +2981,7 @@ mod tests {
     }
 
     /// Multiple distinct partition keys interleaved within a single
-    /// input batch — the per-batch demux, per-partition heap eviction,
+    /// input batch — grouping rows by partition key, per-partition heap 
eviction,
     /// and partition-key-ordered emit must all behave correctly.
     #[tokio::test]
     async fn test_partitioned_topk_multi_partition_within_batch() -> 
Result<()> {
@@ -2831,7 +3275,7 @@ mod tests {
     }
 
     /// Multiple distinct partition keys interleaved within a single
-    /// input batch — the per-batch demux, per-partition heap eviction,
+    /// input batch — grouping rows by partition key, per-partition heap 
eviction,
     /// and partition-key-ordered emit must all behave correctly. No
     /// ties: result should match a `ROW_NUMBER` top-K under the same K.
     #[tokio::test]
@@ -3166,4 +3610,719 @@ mod tests {
         );
         Ok(())
     }
+
+    // ====================================================================
+    // PartitionedTopKDenseRank operator tests
+    //
+    // These mirror the RANK tests plus DENSE_RANK-specific cases: rows
+    // sharing an ob key coalesce into one `GroupEntry`, unbounded
+    // rows-per-distinct-key, and eviction removes the entire max group
+    // when a strictly-smaller distinct ob arrives.
+    // ====================================================================
+
+    /// Builds a `(pk Int32, val Int32)` schema and a
+    /// `PartitionedTopKDenseRank` keyed on `pk ASC` (partition) and
+    /// `val ASC` (ORDER BY).
+    fn build_partitioned_topk_dense_rank(
+        k: usize,
+    ) -> Result<(Arc<Schema>, PartitionedTopKDenseRank)> {
+        build_partitioned_topk_dense_rank_with_opts(k, SortOptions::default(), 
false)
+    }
+
+    fn build_partitioned_topk_dense_rank_with_opts(
+        k: usize,
+        val_sort_options: SortOptions,
+        val_nullable: bool,
+    ) -> Result<(Arc<Schema>, PartitionedTopKDenseRank)> {
+        let schema = Arc::new(Schema::new(vec![
+            Field::new("pk", DataType::Int32, false),
+            Field::new("val", DataType::Int32, val_nullable),
+        ]));
+
+        let pk_expr: Arc<dyn PhysicalExpr> = col("pk", schema.as_ref())?;
+        let pk_sort_expr = PhysicalSortExpr {
+            expr: Arc::clone(&pk_expr),
+            options: SortOptions::default(),
+        };
+        let val_sort_expr = PhysicalSortExpr {
+            expr: col("val", schema.as_ref())?,
+            options: val_sort_options,
+        };
+
+        let partition_sort_fields = build_sort_fields(&[pk_sort_expr], 
&schema)?;
+        let order_expr = LexOrdering::from([val_sort_expr]);
+
+        let state = PartitionedTopKDenseRank::try_new(
+            0,
+            Arc::clone(&schema),
+            vec![pk_expr],
+            partition_sort_fields,
+            order_expr,
+            k,
+            8, // batch_size
+            &Arc::new(RuntimeEnv::default()),
+            &ExecutionPlanMetricsSet::new(),
+        )?;
+        Ok((schema, state))
+    }
+
+    /// Single-batch DENSE_RANK top-2 across multiple partitions with
+    /// distinct ob values only — should behave identically to a
+    /// ROW_NUMBER top-2. Exercises per-partition grouping + emit order.
+    #[tokio::test]
+    async fn test_partitioned_topk_dense_rank_multi_partition_within_batch() 
-> Result<()>
+    {
+        let (schema, mut state) = build_partitioned_topk_dense_rank(2)?;
+
+        // pk=1 vals: 10, 5, 8 → distinct-top-2 ASC = {5, 8}
+        // pk=2 vals: 20, 15   → distinct-top-2 ASC = {15, 20}
+        // pk=3 vals: 7        → distinct-top-2 ASC = {7}
+        let batch =
+            pk_val_batch(&schema, vec![1, 2, 1, 2, 1, 3], vec![10, 20, 5, 15, 
8, 7])?;
+        state.insert_batch(&batch)?;
+
+        let results: Vec<_> = state.emit()?.try_collect().await?;
+        assert_batches_eq!(
+            &[
+                "+----+-----+",
+                "| pk | val |",
+                "+----+-----+",
+                "| 1  | 5   |",
+                "| 1  | 8   |",
+                "| 2  | 15  |",
+                "| 2  | 20  |",
+                "| 3  | 7   |",
+                "+----+-----+",
+            ],
+            &results
+        );
+        Ok(())
+    }
+
+    /// DENSE_RANK-specific: heavy ties within a batch. All rows at each
+    /// distinct ob value must be kept — within-call bucketing groups them
+    /// into one `GroupEntry` per distinct ob.
+    ///
+    /// vals per partition (sorted logically):
+    ///   pk=1: 1, 1, 1, 2, 2, 3, 3, 3, 4
+    ///   distinct-top-2 = {1, 2} → all 5 rows at those values retained.
+    #[tokio::test]
+    async fn test_partitioned_topk_dense_rank_heavy_ties_coalesced() -> 
Result<()> {
+        let (schema, mut state) = build_partitioned_topk_dense_rank(2)?;
+
+        let batch = pk_val_batch(
+            &schema,
+            vec![1, 1, 1, 1, 1, 1, 1, 1, 1],
+            vec![1, 3, 1, 2, 3, 1, 2, 3, 4],
+        )?;
+        state.insert_batch(&batch)?;
+
+        let results: Vec<_> = state.emit()?.try_collect().await?;
+        assert_batches_eq!(
+            &[
+                "+----+-----+",
+                "| pk | val |",
+                "+----+-----+",
+                "| 1  | 1   |",
+                "| 1  | 1   |",
+                "| 1  | 1   |",
+                "| 1  | 2   |",
+                "| 1  | 2   |",
+                "+----+-----+",
+            ],
+            &results
+        );
+        Ok(())
+    }
+
+    /// Rows tied at the same ob across two source batches must both
+    /// land under the same map key as separate `GroupEntry`s — one per
+    /// source batch — but emit as a single contiguous run.
+    #[tokio::test]
+    async fn test_partitioned_topk_dense_rank_cross_batch_same_key() -> 
Result<()> {
+        let (schema, mut state) = build_partitioned_topk_dense_rank(2)?;
+
+        // Batch 1: pk=1 with ob values {5, 5, 8}. groups after: {5→[..], 
8→[..]}.
+        state.insert_batch(&pk_val_batch(&schema, vec![1, 1, 1], vec![5, 5, 
8])?)?;
+
+        // Batch 2: pk=1 with more 5s and an 8, plus a 20 that's dropped.
+        state.insert_batch(&pk_val_batch(&schema, vec![1, 1, 1], vec![5, 8, 
20])?)?;
+
+        let results: Vec<_> = state.emit()?.try_collect().await?;
+        assert_batches_eq!(
+            &[
+                "+----+-----+",
+                "| pk | val |",
+                "+----+-----+",
+                "| 1  | 5   |",
+                "| 1  | 5   |",
+                "| 1  | 5   |",
+                "| 1  | 8   |",
+                "| 1  | 8   |",
+                "+----+-----+",
+            ],
+            &results
+        );
+        Ok(())
+    }
+
+    /// Refactor guard: the full RANK-style path in one run — multi-partition
+    /// per-batch grouping, within-batch bucketing of scattered same-ob rows,
+    /// cross-batch append to an existing group, cross-batch new-key insert,
+    /// and cross-batch eviction of a whole max group. Every `GroupEntry` is
+    /// built from its own source batch (no cross-batch coalescing), so the
+    /// retained rows must be exactly the K=2 smallest distinct ob values
+    /// per partition with all their rows, regardless of arrival order.
+    #[tokio::test]
+    async fn test_partitioned_topk_dense_rank_multi_batch_multi_partition() -> 
Result<()>
+    {
+        let (schema, mut state) = build_partitioned_topk_dense_rank(2)?;
+
+        // Batch 1 interleaves pk=1 and pk=2, with same-ob rows scattered:
+        //   pk=1 vals: 10, 20, 10, 20, 10  → {10:[×3], 20:[×2]}
+        //   pk=2 vals: 100, 100            → {100:[×2]}
+        state.insert_batch(&pk_val_batch(
+            &schema,
+            vec![1, 2, 1, 1, 2, 1, 1],
+            vec![10, 100, 20, 20, 100, 10, 10],
+        )?)?;
+
+        // Batch 2:
+        //   pk=1 vals: 20, 5, 10 → append a 20, insert 5 (evicts the whole
+        //              20 group), append a 10 → retained distinct {5, 10}.
+        //   pk=2 vals: 50        → insert 5th... new key, room → {50, 100}.
+        state.insert_batch(&pk_val_batch(
+            &schema,
+            vec![1, 2, 1, 1],
+            vec![20, 50, 5, 10],
+        )?)?;
+
+        let results: Vec<_> = state.emit()?.try_collect().await?;
+        // pk=1: val=5 (×1 from batch 2), val=10 (×3 batch 1 + ×1 batch 2 = 
×4).
+        //       All 20s dropped (evicted). pk=2: val=50 (×1), val=100 (×2).
+        assert_batches_eq!(
+            &[
+                "+----+-----+",
+                "| pk | val |",
+                "+----+-----+",
+                "| 1  | 5   |",
+                "| 1  | 10  |",
+                "| 1  | 10  |",
+                "| 1  | 10  |",
+                "| 1  | 10  |",
+                "| 2  | 50  |",
+                "| 2  | 100 |",
+                "| 2  | 100 |",
+                "+----+-----+",
+            ],
+            &results
+        );
+        Ok(())
+    }
+
+    /// DENSE_RANK-specific: eviction removes the entire max group when
+    /// a strictly-smaller distinct ob arrives. Multiple rows at the
+    /// evicted key all disappear.
+    #[tokio::test]
+    async fn test_partitioned_topk_dense_rank_max_group_eviction() -> 
Result<()> {
+        let (schema, mut state) = build_partitioned_topk_dense_rank(2)?;
+
+        // Batch 1: pk=1 with {10, 10, 20, 20}. groups={10→[..], 20→[..]}, at 
K.
+        state.insert_batch(&pk_val_batch(
+            &schema,
+            vec![1, 1, 1, 1],
+            vec![10, 10, 20, 20],
+        )?)?;
+
+        // Batch 2: pk=1 with 5 — strictly smaller than max=20, evict entire
+        // 20 group; now groups={10, 5}. Then a 30 comes in and is dropped.
+        state.insert_batch(&pk_val_batch(&schema, vec![1, 1], vec![5, 30])?)?;
+
+        let results: Vec<_> = state.emit()?.try_collect().await?;
+        assert_batches_eq!(
+            &[
+                "+----+-----+",
+                "| pk | val |",
+                "+----+-----+",
+                "| 1  | 5   |",
+                "| 1  | 10  |",
+                "| 1  | 10  |",
+                "+----+-----+",
+            ],
+            &results
+        );
+        Ok(())
+    }
+
+    /// Empty input must produce an empty output stream, not panic.
+    #[tokio::test]
+    async fn test_partitioned_topk_dense_rank_empty_input() -> Result<()> {
+        let (_schema, state) = build_partitioned_topk_dense_rank(3)?;
+        let results: Vec<_> = state.emit()?.try_collect().await?;
+        assert!(results.is_empty(), "empty input → empty output");
+        Ok(())
+    }
+
+    /// `fetch = 1` retains only the smallest distinct ob per partition,
+    /// with all rows at that value kept.
+    #[tokio::test]
+    async fn test_partitioned_topk_dense_rank_fetch_one() -> Result<()> {
+        let (schema, mut state) = build_partitioned_topk_dense_rank(1)?;
+
+        // pk=1 vals: 5, 3, 5, 3, 7 → distinct-top-1 = {3} → both 3s kept.
+        // pk=2 vals: 9, 4          → distinct-top-1 = {4} → single 4.
+        let batch = pk_val_batch(
+            &schema,
+            vec![1, 1, 1, 2, 1, 2, 1],
+            vec![5, 3, 5, 9, 3, 4, 7],
+        )?;
+        state.insert_batch(&batch)?;
+
+        let results: Vec<_> = state.emit()?.try_collect().await?;
+        assert_batches_eq!(
+            &[
+                "+----+-----+",
+                "| pk | val |",
+                "+----+-----+",
+                "| 1  | 3   |",
+                "| 1  | 3   |",
+                "| 2  | 4   |",
+                "+----+-----+",
+            ],
+            &results
+        );
+        Ok(())
+    }
+
+    /// `K > distinct_ob_count` — nothing should be dropped.
+    #[tokio::test]
+    async fn test_partitioned_topk_dense_rank_k_exceeds_distinct() -> 
Result<()> {
+        let (schema, mut state) = build_partitioned_topk_dense_rank(10)?;
+
+        // Only 3 distinct ob values under pk=1; all rows must be retained.
+        let batch = pk_val_batch(&schema, vec![1, 1, 1, 1], vec![5, 3, 3, 7])?;
+        state.insert_batch(&batch)?;
+
+        let results: Vec<_> = state.emit()?.try_collect().await?;
+        assert_batches_eq!(
+            &[
+                "+----+-----+",
+                "| pk | val |",
+                "+----+-----+",
+                "| 1  | 3   |",
+                "| 1  | 3   |",
+                "| 1  | 5   |",
+                "| 1  | 7   |",
+                "+----+-----+",
+            ],
+            &results
+        );
+        Ok(())
+    }
+
+    /// `ORDER BY val DESC` — the row-encoded key ordering must reflect
+    /// the direction so the "distinct-K best" set is the K *largest*
+    /// distinct ob values.
+    #[tokio::test]
+    async fn test_partitioned_topk_dense_rank_desc_ordering() -> Result<()> {
+        let (schema, mut state) = build_partitioned_topk_dense_rank_with_opts(
+            2,
+            SortOptions {
+                descending: true,
+                nulls_first: false,
+            },
+            false,
+        )?;
+
+        // pk=1 vals: 10, 5, 8, 12, 10 → distinct-top-2 DESC = {12, 10}
+        //   → keep both 10s and 12.
+        let batch = pk_val_batch(&schema, vec![1, 1, 1, 1, 1], vec![10, 5, 8, 
12, 10])?;
+        state.insert_batch(&batch)?;
+
+        let results: Vec<_> = state.emit()?.try_collect().await?;
+        assert_batches_eq!(
+            &[
+                "+----+-----+",
+                "| pk | val |",
+                "+----+-----+",
+                "| 1  | 12  |",
+                "| 1  | 10  |",
+                "| 1  | 10  |",
+                "+----+-----+",
+            ],
+            &results
+        );
+        Ok(())
+    }
+
+    /// Cross-partition eviction independence — Case-C eviction in one
+    /// partition must not affect another partition's state.
+    #[tokio::test]
+    async fn test_partitioned_topk_dense_rank_partition_independence() -> 
Result<()> {
+        let (schema, mut state) = build_partitioned_topk_dense_rank(2)?;
+
+        // Batch 1: pk=1 fills {10, 20}; pk=2 fills {30, 40}.
+        state.insert_batch(&pk_val_batch(
+            &schema,
+            vec![1, 1, 2, 2],
+            vec![10, 20, 30, 40],
+        )?)?;
+
+        // Batch 2: pk=1 sees 5 (evicts 20). pk=2 sees 25 (evicts 40).
+        // Each partition's Case-C branch is independent.
+        state.insert_batch(&pk_val_batch(&schema, vec![1, 2], vec![5, 25])?)?;
+
+        let results: Vec<_> = state.emit()?.try_collect().await?;
+        assert_batches_eq!(
+            &[
+                "+----+-----+",
+                "| pk | val |",
+                "+----+-----+",
+                "| 1  | 5   |",
+                "| 1  | 10  |",
+                "| 2  | 25  |",
+                "| 2  | 30  |",
+                "+----+-----+",
+            ],
+            &results
+        );
+        Ok(())
+    }
+
+    /// NULL sort values exercise the shared encoder's null-ordering
+    /// through the row-encoded key byte order. With `ASC NULLS
+    /// LAST`, a NULL is the *largest* distinct ob, so a partition with
+    /// >= K non-NULL distinct values evicts its NULLs, while a partition
+    /// whose only distinct value is NULL still emits it.
+    #[tokio::test]
+    async fn test_partitioned_topk_dense_rank_nulls_last_ordering() -> 
Result<()> {
+        let (schema, mut state) = build_partitioned_topk_dense_rank_with_opts(
+            2,
+            SortOptions {
+                descending: false,
+                nulls_first: false,
+            },
+            true,
+        )?;
+
+        // pk=1 vals: NULL, 10, 20, NULL → distinct-top-2 NULLS LAST = {10, 20}
+        // pk=2 vals: NULL               → distinct-top-2            = {NULL}
+        // pk=3 vals: 3, 3               → distinct-top-2            = {3}
+        let batch = nullable_pk_val_batch(
+            &schema,
+            vec![1, 1, 1, 1, 2, 3, 3],
+            vec![None, Some(10), Some(20), None, None, Some(3), Some(3)],
+        )?;
+        state.insert_batch(&batch)?;
+
+        let results: Vec<_> = state.emit()?.try_collect().await?;
+        assert_batches_eq!(
+            &[
+                "+----+-----+",
+                "| pk | val |",
+                "+----+-----+",
+                "| 1  | 10  |",
+                "| 1  | 20  |",
+                "| 2  |     |",
+                "| 3  | 3   |",
+                "| 3  | 3   |",
+                "+----+-----+",
+            ],
+            &results
+        );
+        Ok(())
+    }
+
+    /// `ASC NULLS FIRST` sorts NULLs *before* every non-NULL value, so a
+    /// NULL is the smallest distinct ob and is kept preferentially. Every
+    /// row at a retained distinct ob — including all tied NULLs — emits.
+    #[tokio::test]
+    async fn test_partitioned_topk_dense_rank_nulls_first_ordering() -> 
Result<()> {
+        let (schema, mut state) = build_partitioned_topk_dense_rank_with_opts(
+            2,
+            SortOptions {
+                descending: false,
+                nulls_first: true,
+            },
+            true,
+        )?;
+
+        // pk=1 vals: NULL, 5, NULL, 8 → distinct-top-2 NULLS FIRST = {NULL, 5}
+        // pk=2 vals: 7, NULL          → distinct-top-2             = {NULL, 7}
+        // pk=3 vals: 3, 1             → distinct-top-2             = {1, 3}
+        let batch = nullable_pk_val_batch(
+            &schema,
+            vec![1, 2, 1, 3, 1, 2, 1, 3],
+            vec![
+                None,
+                Some(7),
+                Some(5),
+                Some(3),
+                None,
+                None,
+                Some(8),
+                Some(1),
+            ],
+        )?;
+        state.insert_batch(&batch)?;
+
+        let results: Vec<_> = state.emit()?.try_collect().await?;
+        assert_batches_eq!(
+            &[
+                "+----+-----+",
+                "| pk | val |",
+                "+----+-----+",
+                "| 1  |     |",
+                "| 1  |     |",
+                "| 1  | 5   |",
+                "| 2  |     |",
+                "| 2  | 7   |",
+                "| 3  | 1   |",
+                "| 3  | 3   |",
+                "+----+-----+",
+            ],
+            &results
+        );
+        Ok(())
+    }
+
+    /// Total `GroupEntry` count across all partitions.
+    fn dense_rank_entry_count(state: &PartitionedTopKDenseRank) -> usize {
+        state
+            .states
+            .values()
+            .flat_map(|s| s.groups.values())
+            .map(|entries| entries.len())
+            .sum()
+    }
+
+    /// One source batch feeding many retained groups must be charged
+    /// once, not once per group.
+    ///
+    /// Dense-rank retains up to K distinct-ob groups per partition and
+    /// each can draw rows from the same batch, so charging per entry
+    /// inflates the reservation by (partitions × K) — here 6× — and can
+    /// trip a spurious `ResourcesExhausted`.
+    #[tokio::test]
+    async fn test_partitioned_topk_dense_rank_charges_batch_once() -> 
Result<()> {
+        let (schema, mut state) = build_partitioned_topk_dense_rank(3)?;
+
+        // pk=1 retains {1,2,3}, pk=2 retains {10,20,30}: 6 groups, all
+        // from this one batch.
+        let batch = pk_val_batch(
+            &schema,
+            vec![1, 1, 1, 1, 2, 2, 2, 2],
+            vec![1, 2, 3, 4, 10, 20, 30, 40],
+        )?;
+        let batch_bytes = get_record_batch_memory_size(&batch);
+        state.insert_batch(&batch)?;
+
+        assert_eq!(dense_rank_entry_count(&state), 6);
+        assert_eq!(state.store.len(), 1);
+        assert_eq!(state.store.batches_size, batch_bytes);
+        Ok(())
+    }
+
+    /// Evicting the last group referencing a batch must release the
+    /// batch's bytes, or the reservation only ever grows.
+    #[tokio::test]
+    async fn test_partitioned_topk_dense_rank_releases_evicted_batch() -> 
Result<()> {
+        let (schema, mut state) = build_partitioned_topk_dense_rank(2)?;
+
+        let first = pk_val_batch(&schema, vec![1, 1], vec![50, 60])?;
+        state.insert_batch(&first)?;
+        assert_eq!(state.store.len(), 1);
+
+        // Both values beat {50, 60}, so every group from `first` is
+        // evicted and only `second` remains charged.
+        let second = pk_val_batch(&schema, vec![1, 1], vec![5, 6])?;
+        let second_bytes = get_record_batch_memory_size(&second);
+        state.insert_batch(&second)?;
+
+        assert_eq!(state.store.len(), 1);
+        assert_eq!(state.store.batches_size, second_bytes);
+
+        let results: Vec<_> = state.emit()?.try_collect().await?;
+        assert_batches_eq!(
+            &[
+                "+----+-----+",
+                "| pk | val |",
+                "+----+-----+",
+                "| 1  | 5   |",
+                "| 1  | 6   |",
+                "+----+-----+",
+            ],
+            &results
+        );
+        Ok(())
+    }
+
+    /// The mirror of the above: a batch whose every run is rejected must
+    /// not be charged at all.
+    ///
+    /// Nothing references it, so nothing would ever release it — charging
+    /// it would pin both the bytes and the batch for the operator's
+    /// lifetime.
+    #[tokio::test]
+    async fn test_partitioned_topk_dense_rank_ignores_fully_rejected_batch() 
-> Result<()>
+    {
+        let (schema, mut state) = build_partitioned_topk_dense_rank(2)?;
+
+        let first = pk_val_batch(&schema, vec![1, 1], vec![5, 6])?;
+        let first_bytes = get_record_batch_memory_size(&first);
+        state.insert_batch(&first)?;
+        assert_eq!(state.store.len(), 1);
+
+        // At K=2 with {5, 6} tracked, both values lose to the boundary, so
+        // no `GroupEntry` points at `second`.
+        state.insert_batch(&pk_val_batch(&schema, vec![1, 1], vec![50, 60])?)?;
+
+        assert_eq!(dense_rank_entry_count(&state), 2);
+        assert_eq!(state.store.len(), 1);
+        assert_eq!(state.store.batches_size, first_bytes);
+        Ok(())
+    }
+
+    /// `keys` must grow on demand rather than reserve K slots when a
+    /// partition is first seen. `size()` charges `keys.capacity()`, so
+    /// eager sizing reserves O(partitions * K) for slots that never hold
+    /// a key — enough to fail a memory limit on a high-cardinality input
+    /// whose partitions each keep a handful of distinct values.
+    #[tokio::test]
+    async fn test_partitioned_topk_dense_rank_heap_grows_on_demand() -> 
Result<()> {
+        const K: usize = 512;
+        const PARTITIONS: usize = 64;
+        let (schema, mut state) = build_partitioned_topk_dense_rank(K)?;
+
+        // One row per partition: every partition holds exactly one
+        // distinct ob value, K - 1 slots short of capacity.
+        let pks: Vec<i32> = (0..PARTITIONS as i32).collect();
+        let vals = pks.clone();
+        state.insert_batch(&pk_val_batch(&schema, pks, vals)?)?;
+
+        assert_eq!(state.states.len(), PARTITIONS);
+        let heap_slots: usize = state.states.values().map(|s| 
s.keys.capacity()).sum();
+        // Eager `with_capacity(K)` would reserve PARTITIONS * K = 32768.
+        assert!(
+            heap_slots <= PARTITIONS * 8,
+            "reserved {heap_slots} heap slots to hold {PARTITIONS} keys"
+        );
+        Ok(())
+    }
+
+    /// The encoded partition keys owned by `states`, and the backing
+    /// tables the drained scratch maps keep, are long-lived heap
+    /// allocations `size()` must charge: they persist for the operator's
+    /// life yet belong to no `GroupEntry`, so per-entry accounting can't
+    /// see them. With numerous or wide partition keys the key bytes are
+    /// the larger term.
+    #[tokio::test]
+    async fn test_partitioned_topk_dense_rank_size_covers_keys_and_scratch() 
-> Result<()>
+    {
+        const PARTITIONS: usize = 64;
+        const KEY_WIDTH: usize = 1024;
+
+        let schema = Arc::new(Schema::new(vec![
+            Field::new("pk", DataType::Utf8, false),
+            Field::new("val", DataType::Int32, false),
+        ]));
+        let pk_expr: Arc<dyn PhysicalExpr> = col("pk", schema.as_ref())?;
+        let partition_sort_fields = build_sort_fields(
+            &[PhysicalSortExpr {
+                expr: Arc::clone(&pk_expr),
+                options: SortOptions::default(),
+            }],
+            &schema,
+        )?;
+        let order_expr = LexOrdering::from([PhysicalSortExpr {
+            expr: col("val", schema.as_ref())?,
+            options: SortOptions::default(),
+        }]);
+        let mut state = PartitionedTopKDenseRank::try_new(
+            0,
+            Arc::clone(&schema),
+            vec![pk_expr],
+            partition_sort_fields,
+            order_expr,
+            4,
+            8, // batch_size
+            &Arc::new(RuntimeEnv::default()),
+            &ExecutionPlanMetricsSet::new(),
+        )?;
+
+        // One row per partition, each with a wide key.
+        let pks: Vec<String> = (0..PARTITIONS)
+            .map(|i| format!("{}{i:04}", "p".repeat(KEY_WIDTH - 4)))
+            .collect();
+        let vals: Vec<i32> = (0..PARTITIONS as i32).collect();
+        state.insert_batch(&RecordBatch::try_new(
+            Arc::clone(&schema),
+            vec![
+                Arc::new(StringArray::from(pks)),
+                Arc::new(Int32Array::from(vals)),
+            ],
+        )?)?;
+        assert_eq!(state.states.len(), PARTITIONS);
+
+        let key_bytes: usize = state.states.keys().map(|pk| 
pk.capacity()).sum();
+        let scratch_bytes = state.partition_groups.capacity()
+            * (size_of::<Vec<u8>>() + size_of::<Vec<u32>>())
+            + state.ob_runs.capacity() * (size_of::<Vec<u8>>() + 
size_of::<Vec<u32>>());
+        assert!(key_bytes >= PARTITIONS * KEY_WIDTH, "key bytes {key_bytes}");
+        assert!(scratch_bytes > 0, "scratch tables never allocated");
+
+        // Reconstruct the total from its parts. Both terms above have to
+        // appear for this to balance, so dropping either from `size()`
+        // fails here rather than being absorbed by the slack in some
+        // other term.
+        let expected = size_of::<PartitionedTopKDenseRank>()
+            + state.row_converter.size()
+            + state.partition_converter.size()
+            + state.scratch_rows.size()
+            + state.partition_scratch_rows.size()
+            + key_bytes
+            + state.states.values().map(|s| s.size()).sum::<usize>()
+            + state.states.capacity()
+                * (size_of::<Vec<u8>>() + size_of::<DenseRankPartitionState>())
+            + scratch_bytes
+            + state.store.size();
+        assert_eq!(state.size(), expected);
+        Ok(())
+    }
+
+    /// A group admitted and then evicted within the *same*
+    /// `insert_batch` call: the batch is still pending (not yet handed to
+    /// the store), so releasing it must decrement the in-flight use count
+    /// rather than call `unuse` on an unregistered id.
+    ///
+    /// `ob_runs` drains in hash order, so with K=1 and many distinct
+    /// values the minimum is almost never seen first and the run
+    /// admit-then-evict path is taken repeatedly.
+    #[tokio::test]
+    async fn test_partitioned_topk_dense_rank_evicts_same_call_group() -> 
Result<()> {
+        let (schema, mut state) = build_partitioned_topk_dense_rank(1)?;
+
+        let pks = vec![1; 32];
+        let vals: Vec<i32> = (0..32).rev().collect();
+        let batch = pk_val_batch(&schema, pks, vals)?;
+        let batch_bytes = get_record_batch_memory_size(&batch);
+        state.insert_batch(&batch)?;
+
+        assert_eq!(dense_rank_entry_count(&state), 1);
+        assert_eq!(state.store.len(), 1);
+        assert_eq!(state.store.batches_size, batch_bytes);
+
+        let results: Vec<_> = state.emit()?.try_collect().await?;
+        assert_batches_eq!(
+            &[
+                "+----+-----+",
+                "| pk | val |",
+                "+----+-----+",
+                "| 1  | 0   |",
+                "+----+-----+",
+            ],
+            &results
+        );
+        Ok(())
+    }
 }
diff --git a/datafusion/sqllogictest/test_files/window_topn.slt 
b/datafusion/sqllogictest/test_files/window_topn.slt
index a9a52a654f..8f65e496b5 100644
--- a/datafusion/sqllogictest/test_files/window_topn.slt
+++ b/datafusion/sqllogictest/test_files/window_topn.slt
@@ -365,9 +365,115 @@ physical_plan
 04)------SortExec: expr=[pk@1 ASC NULLS LAST, val@2 ASC NULLS LAST], 
preserve_partitioning=[false]
 05)--------DataSourceExec: partitions=1, partition_sizes=[1]
 
+# Sibling safety: an aggregate sibling (SUM) is computed over the pruned
+# input, so the rule must NOT fire even though the filter is on ROW_NUMBER.
+# SUM over the default RANGE frame includes tie-peers that pruning would
+# drop, giving a wrong sum. The plan keeps SortExec + FilterExec — no
+# PartitionedTopKExec.
+query TT
+EXPLAIN SELECT * FROM (
+  SELECT *,
+    ROW_NUMBER() OVER (PARTITION BY pk ORDER BY val) as rn,
+    SUM(val) OVER (PARTITION BY pk ORDER BY val) as running_sum
+  FROM window_topn_t
+) WHERE rn <= 3;
+----
+physical_plan
+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]
+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
+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] R [...]
+04)------SortExec: expr=[pk@1 ASC NULLS LAST, val@2 ASC NULLS LAST], 
preserve_partitioning=[false]
+05)--------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# Sibling safety: a LEAD sibling reads following (pruned) rows, so the
+# rule must NOT fire — the boundary row's LEAD would resolve to a pruned
+# row. The plan keeps SortExec + FilterExec.
+query TT
+EXPLAIN SELECT * FROM (
+  SELECT *,
+    ROW_NUMBER() OVER (PARTITION BY pk ORDER BY val) as rn,
+    LEAD(val) OVER (PARTITION BY pk ORDER BY val) as next_val
+  FROM window_topn_t
+) WHERE rn <= 3;
+----
+physical_plan
+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]
+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
+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]  [...]
+04)------SortExec: expr=[pk@1 ASC NULLS LAST, val@2 ASC NULLS LAST], 
preserve_partitioning=[false]
+05)--------DataSourceExec: partitions=1, partition_sizes=[1]
+
 statement ok
 SET datafusion.explain.physical_plan_only = false;
 
+# Sibling safety (correctness): LEAD reads following rows, so if the rule
+# fired, the pruned input would give a wrong LEAD at the retained boundary
+# — each rn=2 row's next_val would become NULL instead of the (pruned)
+# rn=3 row's value. The guard keeps the normal plan, so LEAD sees the full
+# partition and next_val is correct (30 / 25 / 100 for the rn=2 rows).
+query IIIII rowsort
+SELECT id, pk, val, rn, next_val FROM (
+  SELECT *,
+    ROW_NUMBER() OVER (PARTITION BY pk ORDER BY val) as rn,
+    LEAD(val)    OVER (PARTITION BY pk ORDER BY val) as next_val
+  FROM window_topn_t
+) WHERE rn <= 2;
+----
+1 1 10 1 20
+10 3 75 2 100
+2 1 20 2 30
+5 2 5 1 15
+6 2 15 2 25
+9 3 50 1 75
+
+# A tie table for sibling-safety correctness: pk=1 has three peers at
+# val=5, one 10, one 20 (row_number 1..5; rank 1,1,1,4,5; dense_rank
+# 1,1,1,2,3).
+statement ok
+CREATE TABLE window_topn_sib_t (id INT, pk INT, val INT) AS VALUES
+  (1, 1, 5),
+  (2, 1, 5),
+  (3, 1, 5),
+  (4, 1, 10),
+  (5, 1, 20);
+
+# SUM sibling (correctness): fetch=2 but there are three peers at val=5,
+# so one peer would be pruned. The guard keeps the normal plan, so the
+# RANGE frame sees all three 5s and running_sum = 15. If the rule fired,
+# the pruned 2-row input would sum to 10. (id excluded — which two of the
+# three tied rows get row_number 1/2 is not deterministic.)
+query III rowsort
+SELECT pk, val, running_sum FROM (
+  SELECT *,
+    ROW_NUMBER() OVER (PARTITION BY pk ORDER BY val) as rn,
+    SUM(val)     OVER (PARTITION BY pk ORDER BY val) as running_sum
+  FROM window_topn_sib_t
+) WHERE rn <= 2;
+----
+1 5 15
+1 5 15
+
+# All three ranking siblings (ROW_NUMBER + RANK + DENSE_RANK) over the same
+# window are prune-safe, so the rule DOES fire (filter on rn). Verify the
+# rank/dense_rank values stay correct under the optimized plan — including
+# the val=10 row where row_number=4, rank=4, dense_rank=2 all differ.
+query IIII rowsort
+SELECT pk, val, rnk, dr FROM (
+  SELECT *,
+    ROW_NUMBER() OVER (PARTITION BY pk ORDER BY val) as rn,
+    RANK()       OVER (PARTITION BY pk ORDER BY val) as rnk,
+    DENSE_RANK() OVER (PARTITION BY pk ORDER BY val) as dr
+  FROM window_topn_sib_t
+) WHERE rn <= 4;
+----
+1 10 4 2
+1 5 1 1
+1 5 1 1
+1 5 1 1
+
+statement ok
+DROP TABLE window_topn_sib_t;
+
 # Test 15: ROW_NUMBER with DESC ordering — correctness
 query III rowsort
 SELECT id, pk, val FROM (
@@ -1089,6 +1195,353 @@ SELECT id, pk, val FROM (
 statement ok
 DROP TABLE window_topn_rank_null_t;
 
+###############################################################################
+# DENSE_RANK() tests
+###############################################################################
+#
+# DENSE_RANK semantics: `WHERE dr <= K` keeps every row whose ORDER BY
+# value is among the K distinct-smallest ORDER BY values in the
+# partition. The total kept per partition is unbounded in
+# rows-per-distinct-value (unlike RANK, which is bounded above by
+# `K + ties at the boundary`).
+#
+# The tests below exercise:
+#   - heavy ties within a distinct ob key (all rows retained per key)
+#   - cross-batch appends under the same key
+#   - eviction of the entire max-key group when a strictly-smaller
+#     distinct ob arrives
+#   - the empty-ORDER-BY degenerate case (rule must NOT fire)
+
+statement ok
+SET datafusion.optimizer.enable_window_topn = true;
+
+statement ok
+CREATE TABLE window_topn_dense_rank_t (id INT, pk INT, val INT) AS VALUES
+  -- pk=1: distinct top-2 = {10, 20}. Every row at those keeps dr <= 2.
+  --   10 appears once, 20 appears three times (heavy tie), 30/40 dropped.
+  (1, 1, 10),
+  (2, 1, 20),
+  (3, 1, 20),
+  (4, 1, 20),
+  (5, 1, 30),
+  (6, 1, 40),
+  -- pk=2: distinct top-2 = {5, 15}. Two rows at 5, one at 15.
+  (7, 2, 5),
+  (8, 2, 5),
+  (9, 2, 15),
+  (10, 2, 25),
+  -- pk=3: 100 then four 200s — 200 group is the "boundary-max" but with
+  -- dense_rank <= 2 both distinct values (100, 200) are retained.
+  (11, 3, 100),
+  (12, 3, 200),
+  (13, 3, 200),
+  (14, 3, 200),
+  (15, 3, 200),
+  (16, 3, 300);
+
+# Test DR1: Basic DENSE_RANK correctness — every row at the K distinct
+# smallest values is retained.
+query III rowsort
+SELECT id, pk, val FROM (
+  SELECT *, DENSE_RANK() OVER (PARTITION BY pk ORDER BY val) as dr FROM 
window_topn_dense_rank_t
+) WHERE dr <= 2;
+----
+1 1 10
+11 3 100
+12 3 200
+13 3 200
+14 3 200
+15 3 200
+2 1 20
+3 1 20
+4 1 20
+7 2 5
+8 2 5
+9 2 15
+
+# Test DR2: EXPLAIN shows PartitionedTopKExec with fn=dense_rank
+query TT
+EXPLAIN SELECT * FROM (
+  SELECT *, DENSE_RANK() OVER (PARTITION BY pk ORDER BY val) as dr FROM 
window_topn_dense_rank_t
+) WHERE dr <= 2;
+----
+logical_plan
+01)Projection: window_topn_dense_rank_t.id, window_topn_dense_rank_t.pk, 
window_topn_dense_rank_t.val, dense_rank() PARTITION BY 
[window_topn_dense_rank_t.pk] ORDER BY [window_topn_dense_rank_t.val ASC NULLS 
LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW AS dr
+02)--Filter: dense_rank() PARTITION BY [window_topn_dense_rank_t.pk] ORDER BY 
[window_topn_dense_rank_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING 
AND CURRENT ROW <= UInt64(2)
+03)----WindowAggr: windowExpr=[[dense_rank() PARTITION BY 
[window_topn_dense_rank_t.pk] ORDER BY [window_topn_dense_rank_t.val ASC NULLS 
LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW]]
+04)------TableScan: window_topn_dense_rank_t projection=[id, pk, val]
+physical_plan
+01)ProjectionExec: expr=[id@0 as id, pk@1 as pk, val@2 as val, dense_rank() 
PARTITION BY [window_topn_dense_rank_t.pk] ORDER BY 
[window_topn_dense_rank_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING 
AND CURRENT ROW@3 as dr]
+02)--BoundedWindowAggExec: wdw=[dense_rank() PARTITION BY 
[window_topn_dense_rank_t.pk] ORDER BY [window_topn_dense_rank_t.val ASC NULLS 
LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: Field { "dense_rank() 
PARTITION BY [window_topn_dense_rank_t.pk] ORDER BY 
[window_topn_dense_rank_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING 
AND CURRENT ROW": UInt64 }, frame: RANGE BETWEEN UNBOUNDED PRECEDING AND 
CURRENT ROW], mode=[Sorted]
+03)----RepartitionExec: partitioning=Hash([pk@1], 4), input_partitions=1, 
maintains_sort_order=true
+04)------PartitionedTopKExec: fn=dense_rank, fetch=2, partition=[pk@1], 
order=[val@2 ASC NULLS LAST]
+05)--------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# Test DR3: dr < 3 should give the same results (fetch = K-1 = 2)
+query III rowsort
+SELECT id, pk, val FROM (
+  SELECT *, DENSE_RANK() OVER (PARTITION BY pk ORDER BY val) as dr FROM 
window_topn_dense_rank_t
+) WHERE dr < 3;
+----
+1 1 10
+11 3 100
+12 3 200
+13 3 200
+14 3 200
+15 3 200
+2 1 20
+3 1 20
+4 1 20
+7 2 5
+8 2 5
+9 2 15
+
+# Test DR4: flipped predicate (2 >= dr) also fires the rule
+query III rowsort
+SELECT id, pk, val FROM (
+  SELECT *, DENSE_RANK() OVER (PARTITION BY pk ORDER BY val) as dr FROM 
window_topn_dense_rank_t
+) WHERE 2 >= dr;
+----
+1 1 10
+11 3 100
+12 3 200
+13 3 200
+14 3 200
+15 3 200
+2 1 20
+3 1 20
+4 1 20
+7 2 5
+8 2 5
+9 2 15
+
+# Test DR5: K exceeds every partition's distinct count — nothing dropped.
+# pk=1: 4 distinct, pk=2: 3 distinct, pk=3: 3 distinct. dr <= 100 retains all.
+query III rowsort
+SELECT id, pk, val FROM (
+  SELECT *, DENSE_RANK() OVER (PARTITION BY pk ORDER BY val) as dr FROM 
window_topn_dense_rank_t
+) WHERE dr <= 100;
+----
+1 1 10
+10 2 25
+11 3 100
+12 3 200
+13 3 200
+14 3 200
+15 3 200
+16 3 300
+2 1 20
+3 1 20
+4 1 20
+5 1 30
+6 1 40
+7 2 5
+8 2 5
+9 2 15
+
+# Test DR6: DENSE_RANK without PARTITION BY — should NOT trigger the 
optimization
+query TT
+EXPLAIN SELECT * FROM (
+  SELECT *, DENSE_RANK() OVER (ORDER BY val) as dr FROM 
window_topn_dense_rank_t
+) WHERE dr <= 2;
+----
+logical_plan
+01)Projection: window_topn_dense_rank_t.id, window_topn_dense_rank_t.pk, 
window_topn_dense_rank_t.val, dense_rank() ORDER BY 
[window_topn_dense_rank_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING 
AND CURRENT ROW AS dr
+02)--Filter: dense_rank() ORDER BY [window_topn_dense_rank_t.val ASC NULLS 
LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW <= UInt64(2)
+03)----WindowAggr: windowExpr=[[dense_rank() ORDER BY 
[window_topn_dense_rank_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING 
AND CURRENT ROW]]
+04)------TableScan: window_topn_dense_rank_t projection=[id, pk, val]
+physical_plan
+01)ProjectionExec: expr=[id@0 as id, pk@1 as pk, val@2 as val, dense_rank() 
ORDER BY [window_topn_dense_rank_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED 
PRECEDING AND CURRENT ROW@3 as dr]
+02)--FilterExec: dense_rank() ORDER BY [window_topn_dense_rank_t.val ASC NULLS 
LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW@3 <= 2
+03)----BoundedWindowAggExec: wdw=[dense_rank() ORDER BY 
[window_topn_dense_rank_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING 
AND CURRENT ROW: Field { "dense_rank() ORDER BY [window_topn_dense_rank_t.val 
ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW": UInt64 }, 
frame: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW], mode=[Sorted]
+04)------SortExec: expr=[val@2 ASC NULLS LAST], preserve_partitioning=[false]
+05)--------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# Test DR7: DENSE_RANK with empty ORDER BY — degenerate (every row at
+# dense_rank 1), rule must NOT fire.
+query TT
+EXPLAIN SELECT * FROM (
+  SELECT *, DENSE_RANK() OVER (PARTITION BY pk) as dr FROM 
window_topn_dense_rank_t
+) WHERE dr <= 3;
+----
+logical_plan
+01)Projection: window_topn_dense_rank_t.id, window_topn_dense_rank_t.pk, 
window_topn_dense_rank_t.val, dense_rank() PARTITION BY 
[window_topn_dense_rank_t.pk] ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED 
FOLLOWING AS dr
+02)--Filter: dense_rank() PARTITION BY [window_topn_dense_rank_t.pk] ROWS 
BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING <= UInt64(3)
+03)----WindowAggr: windowExpr=[[dense_rank() PARTITION BY 
[window_topn_dense_rank_t.pk] ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED 
FOLLOWING]]
+04)------TableScan: window_topn_dense_rank_t projection=[id, pk, val]
+physical_plan
+01)ProjectionExec: expr=[id@0 as id, pk@1 as pk, val@2 as val, dense_rank() 
PARTITION BY [window_topn_dense_rank_t.pk] ROWS BETWEEN UNBOUNDED PRECEDING AND 
UNBOUNDED FOLLOWING@3 as dr]
+02)--FilterExec: dense_rank() PARTITION BY [window_topn_dense_rank_t.pk] ROWS 
BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING@3 <= 3
+03)----BoundedWindowAggExec: wdw=[dense_rank() PARTITION BY 
[window_topn_dense_rank_t.pk] ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED 
FOLLOWING: Field { "dense_rank() PARTITION BY [window_topn_dense_rank_t.pk] 
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING": UInt64 }, frame: 
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING], mode=[Sorted]
+04)------SortExec: expr=[pk@1 ASC NULLS LAST], preserve_partitioning=[false]
+05)--------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# Test DR8: DESC ordering — distinct-top-2 DESC per partition.
+#   pk=1 DESC {40, 30, 20, 10}: top-2 = {40, 30} → 2 rows
+#   pk=2 DESC {25, 15, 5}:      top-2 = {25, 15} → 2 rows
+#   pk=3 DESC {300, 200, 100}:  top-2 = {300, 200} → 5 rows (200 appears 4x)
+query III rowsort
+SELECT id, pk, val FROM (
+  SELECT *, DENSE_RANK() OVER (PARTITION BY pk ORDER BY val DESC) as dr FROM 
window_topn_dense_rank_t
+) WHERE dr <= 2;
+----
+10 2 25
+12 3 200
+13 3 200
+14 3 200
+15 3 200
+16 3 300
+5 1 30
+6 1 40
+9 2 15
+
+# Test DR9: multi-column PARTITION BY
+query III rowsort
+SELECT id, pk, val FROM (
+  SELECT *, DENSE_RANK() OVER (PARTITION BY pk, id ORDER BY val) as dr FROM 
window_topn_dense_rank_t
+) WHERE dr <= 1;
+----
+1 1 10
+10 2 25
+11 3 100
+12 3 200
+13 3 200
+14 3 200
+15 3 200
+16 3 300
+2 1 20
+3 1 20
+4 1 20
+5 1 30
+6 1 40
+7 2 5
+8 2 5
+9 2 15
+
+# Test DR10: mixed ROW_NUMBER + DENSE_RANK — filter on DR should optimize
+query TT
+EXPLAIN SELECT * FROM (
+  SELECT *,
+    ROW_NUMBER() OVER (PARTITION BY pk ORDER BY val) as rn,
+    DENSE_RANK() OVER (PARTITION BY pk ORDER BY val) as dr
+  FROM window_topn_dense_rank_t
+) WHERE dr <= 2;
+----
+logical_plan
+01)Projection: window_topn_dense_rank_t.id, window_topn_dense_rank_t.pk, 
window_topn_dense_rank_t.val, row_number() PARTITION BY 
[window_topn_dense_rank_t.pk] ORDER BY [window_topn_dense_rank_t.val ASC NULLS 
LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW AS rn, dense_rank() 
PARTITION BY [window_topn_dense_rank_t.pk] ORDER BY 
[window_topn_dense_rank_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING 
AND CURRENT ROW AS dr
+02)--Filter: dense_rank() PARTITION BY [window_topn_dense_rank_t.pk] ORDER BY 
[window_topn_dense_rank_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING 
AND CURRENT ROW <= UInt64(2)
+03)----WindowAggr: windowExpr=[[row_number() PARTITION BY 
[window_topn_dense_rank_t.pk] ORDER BY [window_topn_dense_rank_t.val ASC NULLS 
LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, dense_rank() PARTITION 
BY [window_topn_dense_rank_t.pk] ORDER BY [window_topn_dense_rank_t.val ASC 
NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW]]
+04)------TableScan: window_topn_dense_rank_t projection=[id, pk, val]
+physical_plan
+01)ProjectionExec: expr=[id@0 as id, pk@1 as pk, val@2 as val, row_number() 
PARTITION BY [window_topn_dense_rank_t.pk] ORDER BY 
[window_topn_dense_rank_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING 
AND CURRENT ROW@3 as rn, dense_rank() PARTITION BY 
[window_topn_dense_rank_t.pk] ORDER BY [window_topn_dense_rank_t.val ASC NULLS 
LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW@4 as dr]
+02)--BoundedWindowAggExec: wdw=[row_number() PARTITION BY 
[window_topn_dense_rank_t.pk] ORDER BY [window_topn_dense_rank_t.val ASC NULLS 
LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: Field { "row_number() 
PARTITION BY [window_topn_dense_rank_t.pk] ORDER BY 
[window_topn_dense_rank_t.val ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING 
AND CURRENT ROW": UInt64 }, frame: RANGE BETWEEN UNBOUNDED PRECEDING AND 
CURRENT ROW, dense_rank() PARTITION BY [window_topn_dense_rank_t.pk] OR [...]
+03)----RepartitionExec: partitioning=Hash([pk@1], 4), input_partitions=1, 
maintains_sort_order=true
+04)------PartitionedTopKExec: fn=dense_rank, fetch=2, partition=[pk@1], 
order=[val@2 ASC NULLS LAST]
+05)--------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# Test DR11: `dr < 1` keeps nothing (limit_n = 0). The rule must NOT
+# fire (a fetch=0 PartitionedTopKExec would panic); the ordinary
+# FilterExec returns the empty result.
+query III rowsort
+SELECT id, pk, val FROM (
+  SELECT *, DENSE_RANK() OVER (PARTITION BY pk ORDER BY val) as dr FROM 
window_topn_dense_rank_t
+) WHERE dr < 1;
+----
+
+# Test DR11b: ORDER BY keys fully covered by the PARTITION BY prefix
+# (`PARTITION BY pk ORDER BY pk`). The deduplicated sort ordering is just
+# `[pk]`, so no order key survives past the partition prefix. The rule
+# must NOT fire — a PartitionedTopKExec built with zero order expressions
+# panics on execution. Every row ties at dense_rank 1, so `dr <= 2` keeps
+# the whole table (returned via the ordinary window + filter plan).
+query III rowsort
+SELECT id, pk, val FROM (
+  SELECT *, DENSE_RANK() OVER (PARTITION BY pk ORDER BY pk) as dr FROM 
window_topn_dense_rank_t
+) WHERE dr <= 2;
+----
+1 1 10
+10 2 25
+11 3 100
+12 3 200
+13 3 200
+14 3 200
+15 3 200
+16 3 300
+2 1 20
+3 1 20
+4 1 20
+5 1 30
+6 1 40
+7 2 5
+8 2 5
+9 2 15
+
+statement ok
+DROP TABLE window_topn_dense_rank_t;
+
+# ---------------------------------------------------------------------------
+# DENSE_RANK NULL-in-ORDER-BY ordering
+#
+# Under DENSE_RANK a NULL is a distinct ob value occupying its own rank
+# slot; whether it lands among the K distinct-smallest depends on
+# NULLS FIRST / NULLS LAST. Correctness rests entirely on the
+# byte-comparable row encoding driving the distinct-ob key ordering, so
+# exercise both null placements explicitly.
+# ---------------------------------------------------------------------------
+
+statement ok
+CREATE TABLE window_topn_dr_null_t (id INT, pk INT, val INT) AS VALUES
+  (1, 1, NULL),
+  (2, 1, NULL),
+  (3, 1, 10),
+  (4, 1, 20),
+  (5, 1, 30);
+
+# Test DR12: NULLS FIRST — NULL is the smallest distinct ob (dr 1), then
+# 10 (dr 2). `dr <= 2` keeps both NULL rows and val=10.
+query III rowsort
+SELECT id, pk, val FROM (
+  SELECT *, DENSE_RANK() OVER (PARTITION BY pk ORDER BY val ASC NULLS FIRST) 
as dr
+  FROM window_topn_dr_null_t
+) WHERE dr <= 2;
+----
+1 1 NULL
+2 1 NULL
+3 1 10
+
+# Test DR13: EXPLAIN confirms the NULLS FIRST case uses PartitionedTopKExec.
+query TT
+EXPLAIN SELECT id, pk, val FROM (
+  SELECT *, DENSE_RANK() OVER (PARTITION BY pk ORDER BY val ASC NULLS FIRST) 
as dr
+  FROM window_topn_dr_null_t
+) WHERE dr <= 2;
+----
+logical_plan
+01)Projection: window_topn_dr_null_t.id, window_topn_dr_null_t.pk, 
window_topn_dr_null_t.val
+02)--Filter: dense_rank() PARTITION BY [window_topn_dr_null_t.pk] ORDER BY 
[window_topn_dr_null_t.val ASC NULLS FIRST] RANGE BETWEEN UNBOUNDED PRECEDING 
AND CURRENT ROW <= UInt64(2)
+03)----WindowAggr: windowExpr=[[dense_rank() PARTITION BY 
[window_topn_dr_null_t.pk] ORDER BY [window_topn_dr_null_t.val ASC NULLS FIRST] 
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW]]
+04)------TableScan: window_topn_dr_null_t projection=[id, pk, val]
+physical_plan
+01)ProjectionExec: expr=[id@0 as id, pk@1 as pk, val@2 as val]
+02)--BoundedWindowAggExec: wdw=[dense_rank() PARTITION BY 
[window_topn_dr_null_t.pk] ORDER BY [window_topn_dr_null_t.val ASC NULLS FIRST] 
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: Field { "dense_rank() 
PARTITION BY [window_topn_dr_null_t.pk] ORDER BY [window_topn_dr_null_t.val ASC 
NULLS FIRST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW": UInt64 }, 
frame: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW], mode=[Sorted]
+03)----RepartitionExec: partitioning=Hash([pk@1], 4), input_partitions=1, 
maintains_sort_order=true
+04)------PartitionedTopKExec: fn=dense_rank, fetch=2, partition=[pk@1], 
order=[val@2 ASC]
+05)--------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# Test DR14: NULLS LAST — NULL is the largest distinct ob (dr 4). 10 (dr
+# 1) and 20 (dr 2) are the two smallest, so `dr <= 2` excludes the NULLs.
+query III rowsort
+SELECT id, pk, val FROM (
+  SELECT *, DENSE_RANK() OVER (PARTITION BY pk ORDER BY val ASC NULLS LAST) as 
dr
+  FROM window_topn_dr_null_t
+) WHERE dr <= 2;
+----
+3 1 10
+4 1 20
+
+statement ok
+DROP TABLE window_topn_dr_null_t;
+
 # Reset config to default (false)
 statement ok
 SET datafusion.optimizer.enable_window_topn = false;


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to