parthchandra commented on code in PR #5331:
URL: https://github.com/apache/datafusion-comet/pull/5331#discussion_r4253055853


##########
native/core/src/execution/operators/iceberg_scan.rs:
##########
@@ -647,6 +704,141 @@ mod tests {
             .unwrap();
     }
 
+    fn int_schema() -> arrow::datatypes::SchemaRef {
+        use arrow::datatypes::{DataType, Field, Schema};
+        Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)]))
+    }
+
+    fn single_col_ordering() -> Option<datafusion::physical_expr::LexOrdering> 
{
+        use arrow::compute::SortOptions;
+        use datafusion::physical_expr::expressions::Column;
+        use datafusion::physical_expr::{LexOrdering, PhysicalSortExpr};
+        LexOrdering::new(vec![PhysicalSortExpr {
+            expr: Arc::new(Column::new("a", 0)),
+            options: SortOptions::default(),
+        }])
+    }
+
+    // Builds a scan over three empty-delete tasks with the given reported 
ordering.
+    fn exec_with_ordering(
+        ordering: Option<datafusion::physical_expr::LexOrdering>,
+    ) -> IcebergScanExec {
+        use std::collections::HashMap;
+        let tasks = vec![
+            task_with_deletes(vec![]),
+            task_with_deletes(vec![]),
+            task_with_deletes(vec![]),
+        ];
+        IcebergScanExec::new(
+            "metadata.json".to_string(),
+            int_schema(),
+            HashMap::new(),
+            "cat".to_string(),
+            tasks,
+            1,
+            ordering,
+        )
+        .unwrap()
+    }
+
+    // A reported ordering turns the scan into a multi-partition operator (one 
partition per task)
+    // so a SortPreservingMergeExec above can k-way merge the per-file sorted 
streams.
+    #[test]
+    fn reported_ordering_makes_scan_multi_partition() {
+        let exec = exec_with_ordering(single_col_ordering());
+        assert_eq!(exec.properties().partitioning.partition_count(), 3);
+    }
+
+    // Without a reported ordering the scan stays single-partition (Comet 
drives only execute(0),
+    // which must read every task), preserving the legacy unordered behaviour.
+    #[test]
+    fn no_ordering_keeps_single_partition() {
+        let exec = exec_with_ordering(None);
+        assert_eq!(exec.properties().partitioning.partition_count(), 1);
+    }
+
+    // The ordered scan reads each file as its own sorted partition and relies 
on
+    // SortPreservingMergeExec to k-way merge them into one globally sorted 
stream. This feeds known
+    // sorted partitions (with duplicate keys across partitions, and both asc 
and desc) into that
+    // merge with the same kind of LexOrdering the planner builds, and checks 
the output is globally
+    // sorted and complete. It is deterministic coverage of the merge that 
does not depend on an
+    // ordering-reporting Iceberg build (which is why the end-to-end suite's 
merge assertions cancel
+    // on the published Iceberg used in CI).
+    async fn merge_ints(input: Vec<Vec<i32>>, descending: bool) -> Vec<i32> {

Review Comment:
   thank you! I had a mix up with a force push that removed your merge, but 
have restored order and this is now back.



##########
native/core/src/execution/operators/iceberg_scan.rs:
##########
@@ -707,6 +768,141 @@ mod tests {
             .unwrap();
     }
 
+    fn int_schema() -> arrow::datatypes::SchemaRef {
+        use arrow::datatypes::{DataType, Field, Schema};
+        Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)]))
+    }
+
+    fn single_col_ordering() -> Option<datafusion::physical_expr::LexOrdering> 
{
+        use arrow::compute::SortOptions;
+        use datafusion::physical_expr::expressions::Column;
+        use datafusion::physical_expr::{LexOrdering, PhysicalSortExpr};
+        LexOrdering::new(vec![PhysicalSortExpr {
+            expr: Arc::new(Column::new("a", 0)),
+            options: SortOptions::default(),
+        }])
+    }
+
+    // Builds a scan over three empty-delete tasks with the given reported 
ordering.
+    fn exec_with_ordering(
+        ordering: Option<datafusion::physical_expr::LexOrdering>,
+    ) -> IcebergScanExec {
+        use std::collections::HashMap;
+        let tasks = vec![
+            task_with_deletes(vec![]),
+            task_with_deletes(vec![]),
+            task_with_deletes(vec![]),
+        ];
+        IcebergScanExec::new(
+            "metadata.json".to_string(),
+            int_schema(),
+            HashMap::new(),
+            "cat".to_string(),
+            tasks,
+            1,
+            ordering,
+        )
+        .unwrap()
+    }
+
+    // A reported ordering turns the scan into a multi-partition operator (one 
partition per task)
+    // so a SortPreservingMergeExec above can k-way merge the per-file sorted 
streams.
+    #[test]
+    fn reported_ordering_makes_scan_multi_partition() {
+        let exec = exec_with_ordering(single_col_ordering());
+        assert_eq!(exec.properties().partitioning.partition_count(), 3);
+    }
+
+    // Without a reported ordering the scan stays single-partition (Comet 
drives only execute(0),
+    // which must read every task), preserving the legacy unordered behaviour.
+    #[test]
+    fn no_ordering_keeps_single_partition() {
+        let exec = exec_with_ordering(None);
+        assert_eq!(exec.properties().partitioning.partition_count(), 1);
+    }
+
+    // The ordered scan reads each file as its own sorted partition and relies 
on
+    // SortPreservingMergeExec to k-way merge them into one globally sorted 
stream. This feeds known
+    // sorted partitions (with duplicate keys across partitions, and both asc 
and desc) into that
+    // merge with the same kind of LexOrdering the planner builds, and checks 
the output is globally
+    // sorted and complete. It is deterministic coverage of the merge that 
does not depend on an
+    // ordering-reporting Iceberg build (which is why the end-to-end suite's 
merge assertions cancel
+    // on the published Iceberg used in CI).
+    async fn merge_ints(input: Vec<Vec<i32>>, descending: bool) -> Vec<i32> {

Review Comment:
    I kept the #6524 test and un-ignored it as the regression guard now that 
the shared-reader fix is in (shared_delete_file_read_once_in_ordered_path).



##########
spark/src/test/scala/org/apache/comet/CometIcebergSortMergeReadSuite.scala:
##########
@@ -0,0 +1,641 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.comet
+
+import java.util.concurrent.atomic.AtomicInteger
+
+import org.apache.spark.sql.CometTestBase
+import org.apache.spark.sql.catalyst.expressions.{Add, Ascending, 
AttributeReference, Literal, SortOrder}
+import org.apache.spark.sql.comet.{CometIcebergNativeScanExec, CometSortExec}
+import org.apache.spark.sql.comet.execution.shuffle.CometShuffleExchangeExec
+import org.apache.spark.sql.execution.{SortExec, SparkPlan}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.execution.exchange.ShuffleExchangeExec
+import org.apache.spark.sql.types.IntegerType
+
+import org.apache.comet.serde.operator.CometIcebergNativeScan
+
+/**
+ * Tests for the sort-aware native Iceberg scan (branch `stream-merge`): the 
scan reports the
+ * Iceberg table sort order to Spark and does a per-partition streaming k-way 
merge of the
+ * already-sorted files, so Catalyst can drop the Sort and (with 
storage-partitioned join) the
+ * Exchange that a sort-merge join / grouped aggregate / window would 
otherwise need.
+ *
+ * The suite has two parts:
+ *   - Unit tests of the [[CometIcebergNativeScan.reportableOrdering]] gate 
(no SparkSession
+ *     required) -- the single decision shared by the proto serialization 
(which turns on the
+ *     native SortPreservingMergeExec) and 
CometIcebergNativeScanExec.outputOrdering (which tells
+ *     Spark the scan is sorted).
+ *   - End-to-end tests over real Iceberg tables that exercise every Spark 4.0 
mechanism which
+ *     exploits already-sorted input to avoid a Sort or a shuffle (all keyed 
off
+ *     `SortOrder.orderingSatisfies`, prefix semantics): 
`SupportsReportOrdering` ->
+ *     `BatchScanExec.outputOrdering` and `EnsureRequirements` eliding the 
required-child Sort;
+ *     `EliminateSorts` / `RemoveRedundantSorts`; 
`SortMergeJoinExec.requiredChildOrdering` +
+ *     storage-partitioned join (`KeyGroupedPartitioning`,
+ *     `spark.sql.sources.v2.bucketing.enabled`); `ReplaceHashWithSortAgg` / 
`SortAggregateExec`;
+ *     `WindowExec`; `TakeOrderedAndProjectExec`.
+ *
+ * Two invariants determine the end-to-end assertions:
+ *   1. Correctness is checked unconditionally via `checkSparkAnswer` (Comet 
vs vanilla Spark).
+ *      This is the primary guarantee: any k-way-merge defect (dropped, 
duplicated or mis-ordered
+ *      rows, or an outputOrdering/outputPartitioning that does not match the 
rows the native
+ *      operator actually produces) shows up as a result mismatch. It holds on 
any Iceberg build.
+ *      2. The strict "no Sort / no Exchange" plan assertions are the *target* 
of this feature.
+ *      They only hold where the Iceberg build actually reports the ordering
+ *      (`SupportsReportOrdering`, today an Iceberg fork feature -- the 
published/upstream Iceberg
+ *      used in CI does not report it) and where the native scan reports 
`KeyGroupedPartitioning`.
+ *      Each such test therefore runs the correctness check first, then 
`assume`s the reporting is
+ *      active before asserting the plan shape, so it enforces the contract on 
a reporting build
+ *      and is skipped (not failed) elsewhere. `sort = 0` is asserted only for 
*operator-required*
+ *      orderings (SMJ / aggregate / window), never for a global `ORDER BY`, 
which Spark keeps
+ *      regardless (the per-partition merge is not a global order).
+ *
+ * These cannot be SQL-file fixtures: setting an Iceberg sort order needs the 
Iceberg Java API
+ * (the Comet test session registers no Iceberg SQL extensions, so `WRITE 
ORDERED BY` will not
+ * parse), and the plan-shape assertions need access to the executed SparkPlan.
+ *
+ * Each test gets its own catalog name and temp warehouse (the Hadoop 
`SparkCatalog` instance is
+ * cached per catalog name, so a shared name would bind every test to the 
first warehouse), and
+ * its tables are dropped in a `finally` so a failing test cannot leak a table 
into a later one.
+ */
+class CometIcebergSortMergeReadSuite
+    extends CometTestBase
+    with CometIcebergTestBase
+    with AdaptiveSparkPlanHelper {
+
+  // 
---------------------------------------------------------------------------------------------
+  // Unit tests of the reportableOrdering gate (merged from the former 
CometIcebergSortMergeSuite).
+  // v1 identity-scope gate as a pure function, independent of a live 
SparkSession or an Iceberg
+  // build that reports ordering. The flag defaults to enabled, so no SQLConf 
override is needed.
+  // 
---------------------------------------------------------------------------------------------
+
+  private val gateA = AttributeReference("a", IntegerType)()
+  private val gateB = AttributeReference("b", IntegerType)()
+
+  test("gate: identity ordering on projected columns is reportable") {
+    val ordering = Seq(SortOrder(gateA, Ascending))
+    assert(
+      CometIcebergNativeScan.reportableOrdering(Some(ordering), Seq(gateA, 
gateB)) === ordering)
+  }
+
+  test("gate: ordering on a column outside the projection falls back") {
+    val ordering = Seq(SortOrder(gateA, Ascending))
+    assert(CometIcebergNativeScan.reportableOrdering(Some(ordering), 
Seq(gateB)).isEmpty)
+  }
+
+  test("gate: a transform (non-AttributeReference) sort child falls back") {
+    val ordering = Seq(SortOrder(Add(gateA, Literal(1)), Ascending))
+    assert(CometIcebergNativeScan.reportableOrdering(Some(ordering), 
Seq(gateA)).isEmpty)
+  }
+
+  test("gate: if any sort field is unreportable, the whole ordering falls 
back") {
+    val ordering = Seq(SortOrder(gateA, Ascending), SortOrder(Add(gateB, 
Literal(1)), Ascending))
+    assert(CometIcebergNativeScan.reportableOrdering(Some(ordering), 
Seq(gateA, gateB)).isEmpty)
+  }
+
+  test("gate: absent or empty ordering falls back") {
+    assert(CometIcebergNativeScan.reportableOrdering(None, Seq(gateA)).isEmpty)
+    assert(CometIcebergNativeScan.reportableOrdering(Some(Seq.empty), 
Seq(gateA)).isEmpty)
+  }
+
+  test("gate: a sort key on an ordering-unsafe column (e.g. UUID) falls back") 
{
+    // "a" stands in for a UUID column: Iceberg maps it to StringType but 
sorts by its own order,
+    // so it is unsafe to honour even though it looks like a plain string at 
the Spark level.
+    val ordering = Seq(SortOrder(gateA, Ascending))
+    assert(
+      CometIcebergNativeScan
+        .reportableOrdering(Some(ordering), Seq(gateA, gateB), Set("a"))
+        .isEmpty)
+  }
+
+  // 
---------------------------------------------------------------------------------------------
+  // End-to-end fixtures.
+  // 
---------------------------------------------------------------------------------------------
+
+  private val catalogCounter = new AtomicInteger(0)
+
+  // preserve-data-ordering makes Iceberg report the table sort order; 
adaptive off keeps the
+  // executed plan stable for the sort/shuffle counts below.
+  private val orderedReadConf: Seq[(String, String)] = Seq(
+    "spark.sql.iceberg.planning.preserve-data-ordering" -> "true",
+    "spark.sql.adaptive.enabled" -> "false")
+
+  // Storage-partitioned join config. preserve-data-grouping + v2 bucketing 
let Iceberg report
+  // KeyGroupedPartitioning on the BatchScanExec, and the join knobs force a 
sort-merge join over
+  // co-partitioned inputs so the Exchange can be eliminated. Comet does not 
report partitioning
+  // itself; Spark's EnsureRequirements eliminates the shuffle on the 
BatchScanExec before Comet
+  // converts the scan. Adaptive off keeps the executed plan stable for the 
counts below.
+  private val spjConf: Seq[(String, String)] = Seq(
+    "spark.sql.iceberg.planning.preserve-data-ordering" -> "true",
+    "spark.sql.iceberg.planning.preserve-data-grouping" -> "true",
+    "spark.sql.sources.v2.bucketing.enabled" -> "true",
+    "spark.sql.sources.v2.bucketing.pushPartValues.enabled" -> "true",
+    "spark.sql.requireAllClusterKeysForCoPartition" -> "false",
+    "spark.sql.autoBroadcastJoinThreshold" -> "-1",
+    "spark.sql.join.preferSortMergeJoin" -> "true",
+    "spark.sql.adaptive.enabled" -> "false")
+
+  /**
+   * Runs `f` against a fresh, uniquely-named Hadoop catalog backed by a fresh 
temp warehouse,
+   * then drops the named tables (IF EXISTS) in a `finally` -- so tables are 
cleaned up even when
+   * the test body fails, and no two tests can collide on a table name. `f` 
receives the catalog
+   * name; tables live under the `db` namespace, e.g. `$cat.db.$table`.
+   */
+  private def withSortedTables(extraConf: Seq[(String, String)])(tables: 
String*)(
+      f: String => Unit): Unit = {
+    assume(icebergAvailable, "Iceberg not available in classpath")
+    withTempIcebergDir { warehouseDir =>
+      val cat = s"sort_cat_${catalogCounter.incrementAndGet()}"
+      val cometConf = Seq(
+        s"spark.sql.catalog.$cat" -> "org.apache.iceberg.spark.SparkCatalog",
+        s"spark.sql.catalog.$cat.type" -> "hadoop",
+        s"spark.sql.catalog.$cat.warehouse" -> warehouseDir.getAbsolutePath,
+        CometConf.COMET_ENABLED.key -> "true",
+        CometConf.COMET_EXEC_ENABLED.key -> "true",
+        CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true")
+      withSQLConf((cometConf ++ extraConf): _*) {
+        try f(cat)
+        finally tables.foreach(t => spark.sql(s"DROP TABLE IF EXISTS 
$cat.db.$t"))
+      }
+    }
+  }
+
+  /**
+   * Sets the table sort order via the Iceberg Java API. `cols` is (column, 
ascending); ascending
+   * uses Iceberg's default NULLS FIRST and descending its default NULLS LAST, 
matching the ORDER
+   * BY null-ordering the tests use.
+   */
+  private def replaceSortOrder(
+      cat: String,
+      namespace: String,
+      table: String,
+      cols: (String, Boolean)*): Unit = {
+    val catalog = spark.sessionState.catalogManager
+      .catalog(cat)
+      .asInstanceOf[org.apache.iceberg.spark.SparkCatalog]
+    val ident =
+      org.apache.spark.sql.connector.catalog.Identifier.of(Array(namespace), 
table)
+    val icebergTable = catalog
+      .loadTable(ident)
+      .asInstanceOf[org.apache.iceberg.spark.source.SparkTable]
+      .table()
+    var sortOrder = icebergTable.replaceSortOrder()
+    cols.foreach { case (c, asc) =>
+      sortOrder = if (asc) sortOrder.asc(c) else sortOrder.desc(c)
+    }
+    sortOrder.commit()
+  }
+
+  /**
+   * Each string becomes a separate INSERT, hence a separate data file, so 
merging is required.
+   */
+  private def insertBatches(cat: String, table: String, batches: String*): 
Unit =
+    batches.foreach(values => spark.sql(s"INSERT INTO $cat.db.$table VALUES 
$values"))
+
+  private def nativeScans(plan: SparkPlan): Seq[CometIcebergNativeScanExec] =
+    collect(stripAQEPlan(plan)) { case s: CometIcebergNativeScanExec => s }
+
+  private def countSorts(plan: SparkPlan): Int =
+    collect(stripAQEPlan(plan)) {
+      case s: SortExec => s
+      case s: CometSortExec => s
+    }.size
+
+  private def countShuffles(plan: SparkPlan): Int =
+    collect(stripAQEPlan(plan)) {
+      case e: ShuffleExchangeExec => e
+      case e: CometShuffleExchangeExec => e
+    }.size
+
+  /** True once every native scan in the plan advertises the reported 
ordering. */
+  private def orderingReported(plan: SparkPlan): Boolean = {
+    val scans = nativeScans(plan)
+    scans.nonEmpty && scans.forall(_.outputOrdering.nonEmpty)
+  }
+
+  // NOTE ON CANCELED TESTS: the helper below CANCELS the test (ScalaTest 
`assume`, reported as
+  // "!!! CANCELED !!!", not a failure) when the Iceberg build on the 
classpath does not implement
+  // the DSv2 `SupportsReportOrdering` API. Published/upstream Iceberg (the 
runtime used in CI and
+  // the default mvn profiles) does not report a sort order, so 
`outputOrdering` comes back empty
+  // and there is no eliminated Sort to assert on. These tests therefore show 
as canceled there --
+  // that is expected, NOT a regression. The preceding `checkSparkAnswer` has 
already validated
+  // correctness; only the sort/shuffle-elimination plan assertion is skipped. 
Run against an
+  // ordering-reporting (fork) Iceberg build to exercise those assertions.
+
+  /** Cancels the test (see note above) unless the scan reported an ordering. 
*/
+  private def assumeOrderingReported(plan: SparkPlan): Unit =
+    assume(
+      orderingReported(plan),
+      "current Iceberg build does not implement SupportsReportOrdering (no 
ordering reported); " +
+        "sort-elimination assertion skipped")
+
+  // The reporting mechanism: SupportsReportOrdering -> 
CometIcebergNativeScanExec.outputOrdering
+
+  test("native scan reports the table sort order for a multi-file sorted 
table") {
+    withSortedTables(orderedReadConf)("t") { cat =>
+      spark.sql(s"CREATE TABLE $cat.db.t (id INT, data STRING) USING iceberg")
+      replaceSortOrder(cat, "db", "t", "id" -> true)
+      insertBatches(cat, "t", "(1,'a'),(3,'c')", "(2,'b'),(4,'d')")
+
+      val (_, plan) = checkSparkAnswer(s"SELECT id, data FROM $cat.db.t ORDER 
BY id")
+      assume(nativeScans(plan).nonEmpty, "query did not use the native Iceberg 
scan")
+      assumeOrderingReported(plan)
+      assert(
+        
nativeScans(plan).head.outputOrdering.head.child.references.exists(_.name == 
"id"),
+        s"expected the scan to report an ordering on id:\n$plan")
+    }
+  }
+
+  test("native scan reports no ordering when the sort-merge flag is disabled") 
{
+    withSortedTables(
+      orderedReadConf ++ Seq(CometConf.COMET_ICEBERG_SORT_MERGE_ENABLED.key -> 
"false"))("t") {
+      cat =>
+        spark.sql(s"CREATE TABLE $cat.db.t (id INT, data STRING) USING 
iceberg")
+        replaceSortOrder(cat, "db", "t", "id" -> true)
+        insertBatches(cat, "t", "(1,'a'),(3,'c')", "(2,'b'),(4,'d')")
+
+        // Correctness must hold with the feature off, and no ordering may be 
advertised.
+        val (_, plan) = checkSparkAnswer(s"SELECT id, data FROM $cat.db.t 
ORDER BY id")
+        nativeScans(plan).foreach { scan =>
+          assert(
+            scan.outputOrdering.isEmpty,
+            s"no ordering must be reported when the flag is off:\n$plan")
+        }
+    }
+  }
+
+  // K-way merge correctness (EnsureRequirements / EliminateSorts consume the 
reported ordering;
+  // ORDER BY keeps the comparison order-sensitive so a merge defect is caught 
directly).
+
+  test("merges multiple sorted files into one globally-ordered stream") {
+    withSortedTables(orderedReadConf)("t") { cat =>
+      spark.sql(s"CREATE TABLE $cat.db.t (id INT, data STRING) USING iceberg")
+      replaceSortOrder(cat, "db", "t", "id" -> true)
+      insertBatches(
+        cat,
+        "t",
+        "(1,'a'),(2,'b')",
+        "(3,'c'),(4,'d')",
+        "(5,'e'),(6,'f')",
+        "(7,'g'),(8,'h')")
+
+      val (_, plan) = checkSparkAnswer(s"SELECT id, data FROM $cat.db.t ORDER 
BY id")
+      assert(nativeScans(plan).length == 1, s"expected exactly one native 
scan:\n$plan")
+    }
+  }
+
+  test("merge interleaves duplicate sort-key values across files") {
+    withSortedTables(orderedReadConf)("t") { cat =>
+      spark.sql(s"CREATE TABLE $cat.db.t (id INT, data STRING) USING iceberg")
+      replaceSortOrder(cat, "db", "t", "id" -> true)
+      // The same id appears in several files; the merge must keep every row, 
not drop or mis-order.
+      insertBatches(cat, "t", "(1,'a'),(2,'b')", "(1,'c'),(2,'d')", 
"(1,'e'),(3,'f')")
+
+      checkSparkAnswer(s"SELECT id, data FROM $cat.db.t ORDER BY id, data")
+    }
+  }
+
+  test("merge applies merge-on-read deletes across a multi-file sorted 
partition") {
+    withSortedTables(orderedReadConf)("t") { cat =>
+      spark.sql(
+        s"CREATE TABLE $cat.db.t (id INT, data STRING) USING iceberg " +
+          "TBLPROPERTIES ('format-version'='2', 
'write.delete.mode'='merge-on-read')")
+      replaceSortOrder(cat, "db", "t", "id" -> true)
+      // Several files so a merge is required; then delete rows from some of 
them. On a v2
+      // merge-on-read table DELETE writes delete files rather than rewriting 
the data files, so
+      // the scan must apply the deletes while merging the still-sorted files.
+      insertBatches(cat, "t", "(1,'a'),(4,'d')", "(2,'b'),(5,'e')", 
"(3,'c'),(6,'f')")
+      spark.sql(s"DELETE FROM $cat.db.t WHERE id IN (2, 5)")
+
+      checkSparkAnswer(s"SELECT id, data FROM $cat.db.t ORDER BY id")
+    }
+  }
+
+  test("merge honours NULLS FIRST on an ascending sort key") {
+    withSortedTables(spjConf)("t") { cat =>
+      spark.sql(
+        s"CREATE TABLE $cat.db.t (c1 INT, c2 STRING, c3 STRING) USING iceberg 
" +
+          "PARTITIONED BY (c3)")
+      replaceSortOrder(cat, "db", "t", "c1" -> true) // ASC -> Iceberg default 
NULLS FIRST
+      insertBatches(
+        cat,
+        "t",
+        "(null,'x','P1'),(3,'c','P1')",
+        "(null,'y','P1'),(1,'a','P1'),(2,'b','P1')")
+
+      checkSparkAnswer(
+        s"SELECT c1, c2 FROM $cat.db.t WHERE c3 = 'P1' ORDER BY c1 ASC NULLS 
FIRST, c2")
+    }
+  }
+
+  test("merge honours NULLS LAST on a descending sort key") {
+    withSortedTables(spjConf)("t") { cat =>
+      spark.sql(
+        s"CREATE TABLE $cat.db.t (c1 INT, c2 STRING, c3 STRING) USING iceberg 
" +
+          "PARTITIONED BY (c3)")
+      replaceSortOrder(cat, "db", "t", "c1" -> false) // DESC -> Iceberg 
default NULLS LAST
+      insertBatches(
+        cat,
+        "t",
+        "(null,'x','P1'),(1,'a','P1')",
+        "(null,'y','P1'),(3,'c','P1'),(2,'b','P1')")
+
+      checkSparkAnswer(
+        s"SELECT c1, c2 FROM $cat.db.t WHERE c3 = 'P1' ORDER BY c1 DESC NULLS 
LAST, c2")
+    }
+  }
+
+  test("merge on a descending sort order") {
+    withSortedTables(orderedReadConf)("t") { cat =>
+      spark.sql(s"CREATE TABLE $cat.db.t (id INT, data STRING) USING iceberg")
+      replaceSortOrder(cat, "db", "t", "id" -> false)
+      insertBatches(cat, "t", "(10,'j'),(9,'i')", "(8,'h'),(7,'g')", 
"(6,'f'),(4,'d')")
+
+      checkSparkAnswer(s"SELECT id FROM $cat.db.t ORDER BY id DESC")
+    }
+  }
+
+  test("merge on a multi-column sort order") {
+    withSortedTables(orderedReadConf)("t") { cat =>
+      spark.sql(s"CREATE TABLE $cat.db.t (c1 INT, c2 STRING, c3 STRING) USING 
iceberg")
+      replaceSortOrder(cat, "db", "t", "c3" -> true, "c1" -> true)
+      insertBatches(
+        cat,
+        "t",
+        "(1,'a','A'),(3,'c','A')",
+        "(2,'b','A'),(1,'a','B')",
+        "(2,'b','B'),(3,'c','B')")
+
+      checkSparkAnswer(s"SELECT c3, c1, c2 FROM $cat.db.t ORDER BY c3, c1")
+    }
+  }
+
+  test("single file needs no merge") {
+    withSortedTables(orderedReadConf)("t") { cat =>
+      spark.sql(s"CREATE TABLE $cat.db.t (id INT, data STRING) USING iceberg")
+      replaceSortOrder(cat, "db", "t", "id" -> true)
+      insertBatches(cat, "t", "(1,'a'),(2,'b')")
+
+      checkSparkAnswer(s"SELECT id, data FROM $cat.db.t ORDER BY id")
+    }
+  }
+
+  test("partitioned table with several files per partition") {

Review Comment:
    Added two tests that do exactly this, both inserting many single-row files 
into one partition
    - "many small files in one partition merge correctly" raises 
sortMerge.maxFilesPerPartition to 1000 so the k-way merge path runs with many 
files, and checks the output matches Spark.
    - "many small files in one partition stay correct (exercises the sort 
fallback)" inserts 70 single-row files into one partition, which is above the 
default cap, so it takes the spillable-sort fallback instead.
   
    That gives both the merge and the fallback a correctness check at the 
file-count shape the feature targets. Agreed that asserting on peak concurrent 
readers / memory pool is the natural next step once #5343 lands the 
bound-driven admission.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to