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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 92404efb76 [spark] Add opt-in repartitioning for oversized Blob table 
scans (#10457)
92404efb76 is described below

commit 92404efb769dabd7d53e1bfe59df7041975ab9d2
Author: Zhou <[email protected]>
AuthorDate: Wed Oct 14 10:03:40 2026 +0800

    [spark] Add opt-in repartitioning for oversized Blob table scans (#10457)
---
 docs/docs/spark/sql-query.md                       |  49 +++
 docs/generated/spark_connector_configuration.html  |   6 +
 .../apache/paimon/spark/SparkConnectorOptions.java |  12 +
 .../optimizer/RepartitionLargeBlobScan.scala       | 131 +++++++
 .../extensions/PaimonSparkSessionExtensions.scala  |   8 +-
 .../spark/RepartitionLargeBlobScanTest.scala       | 407 +++++++++++++++++++++
 6 files changed, 612 insertions(+), 1 deletion(-)

diff --git a/docs/docs/spark/sql-query.md b/docs/docs/spark/sql-query.md
index 71883eeae0..bd3a12f368 100644
--- a/docs/docs/spark/sql-query.md
+++ b/docs/docs/spark/sql-query.md
@@ -167,6 +167,55 @@ To inspect row kinds, including deletes, query the 
[`audit_log` system table](..
 
 ## Query Optimization
 
+### Repartition Oversized Scan Output
+
+This optimization is disabled by default. Enable it for the Spark session with:
+
+```sql
+SET spark.paimon.read.repartition-large-blob-scan.enabled = true;
+```
+
+Set it to `false` to disable `RepartitionLargeBlobScan`. When disabled, the 
rule
+returns the plan unchanged without planning scan splits or input partitions.
+
+This rule only applies to tables whose full schema contains a `BLOB`, 
`ARRAY<BLOB>`,
+or `MAP<..., BLOB>` column, even if that column is pruned from the scan. Other 
tables
+are skipped without planning scan splits or input partitions.
+
+When an eligible Paimon batch scan has an input partition larger than twice 
`filesMaxPartitionBytes`,
+the optimizer inserts a shuffle immediately after the scan. The trigger 
threshold and
+output partition count are calculated separately:
+
+```text
+threshold = 2 * filesMaxPartitionBytes
+output partition count = ceil(total input partition bytes / 
filesMaxPartitionBytes)
+```
+
+If the computed output partition count is less than or equal to the existing 
input partition
+count, the rule keeps the scan unchanged without adding a shuffle. A shuffle 
is inserted only
+when the computed count increases parallelism.
+
+`filesMaxPartitionBytes` uses Paimon's `source.split.target-size`, falling 
back to an explicitly
+configured `spark.sql.files.maxPartitionBytes`, then Paimon's default split 
size.
+All input partitions contribute to the total bytes. An input partition exactly 
at the
+threshold does not trigger repartitioning. File-open costs and minimum 
partition counts
+do not affect this rule's threshold or output partition count.
+
+Sizes are the sum of data file sizes reported by the splits in each input 
partition,
+including Blob file sizes. They are metadata estimates, not decoded row sizes 
or
+measured shuffle bytes. The rule increases parallelism for downstream 
operators;
+it does not split source scan tasks. It leaves streaming scans and an existing
+shuffle directly above the scan unchanged. Runtime filtering can subsequently
+reduce the scan input without changing this statically planned repartition 
count.
+
+The shuffle is placed above an existing dynamic partition pruning filter, 
keeping the
+filter adjacent to its scan operation so Spark can pass runtime filters to 
Paimon.
+Scans below expressions using `input_file_name()`, `input_file_block_start()`, 
or
+`input_file_block_length()` are skipped: these functions need the source 
reader's
+task-local file context, which is not carried through a shuffle.
+
+### Filter Pushdown
+
 It is highly recommended to specify partition and primary key filters
 along with the query, which will speed up the data skipping of the query.
 
diff --git a/docs/generated/spark_connector_configuration.html 
b/docs/generated/spark_connector_configuration.html
index 61684d295f..281ac494c3 100644
--- a/docs/generated/spark_connector_configuration.html
+++ b/docs/generated/spark_connector_configuration.html
@@ -56,6 +56,12 @@ under the License.
             <td>Boolean</td>
             <td>Whether to read row in the form of changelog (add rowkind 
column in row to represent its change type).</td>
         </tr>
+        <tr>
+            <td><h5>read.repartition-large-blob-scan.enabled</h5></td>
+            <td style="word-wrap: break-word;">false</td>
+            <td>Boolean</td>
+            <td>Whether to insert a shuffle after a batch scan of a table with 
BLOB columns (including ARRAY&lt;BLOB&gt; and MAP values of BLOB) when any 
input partition exceeds twice filesMaxPartitionBytes. The shuffle partition 
count is the total input bytes divided by filesMaxPartitionBytes, rounded up. 
Skips the shuffle if this count is no greater than the existing input partition 
count. Does not change the number of scan tasks.</td>
+        </tr>
         <tr>
             <td><h5>read.stream.maxBytesPerTrigger</h5></td>
             <td style="word-wrap: break-word;">(none)</td>
diff --git 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java
 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java
index 82d6855589..7368a7a03f 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java
+++ 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java
@@ -168,6 +168,18 @@ public class SparkConnectorOptions {
                     .withDescription(
                             "Whether to allow full scan when reading a 
partitioned table.");
 
+    public static final ConfigOption<Boolean> 
READ_REPARTITION_LARGE_BLOB_SCAN_ENABLED =
+            key("read.repartition-large-blob-scan.enabled")
+                    .booleanType()
+                    .defaultValue(false)
+                    .withDescription(
+                            "Whether to insert a shuffle after a batch scan of 
a table with BLOB columns "
+                                    + "(including ARRAY<BLOB> and MAP values 
of BLOB) when any input partition "
+                                    + "exceeds twice filesMaxPartitionBytes. 
The shuffle partition count is "
+                                    + "the total input bytes divided by 
filesMaxPartitionBytes, rounded up. "
+                                    + "Skips the shuffle if this count is no 
greater than the existing input partition count. "
+                                    + "Does not change the number of scan 
tasks.");
+
     public static final ConfigOption<Boolean> 
FORMAT_TABLE_REPAIR_COLLECT_STATISTICS =
             key("format-table.repair.collect-statistics")
                     .booleanType()
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/RepartitionLargeBlobScan.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/RepartitionLargeBlobScan.scala
new file mode 100644
index 0000000000..6d127823f7
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/RepartitionLargeBlobScan.scala
@@ -0,0 +1,131 @@
+/*
+ * 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.paimon.spark.catalyst.optimizer
+
+import org.apache.paimon.spark.{PaimonScan, SparkConnectorOptions}
+import org.apache.paimon.spark.read.BinPackingSplits
+import org.apache.paimon.spark.util.{OptionUtils, SplitUtils}
+import org.apache.paimon.types.BlobType
+
+import org.apache.spark.sql.SparkSession
+import org.apache.spark.sql.catalyst.expressions.{DynamicPruning, 
InputFileBlockLength, InputFileBlockStart, InputFileName}
+import org.apache.spark.sql.catalyst.planning.PhysicalOperation
+import org.apache.spark.sql.catalyst.plans.logical.{Filter, LogicalPlan, 
Repartition, RepartitionOperation}
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2ScanRelation
+import org.apache.spark.sql.internal.SQLConf
+
+import scala.collection.JavaConverters._
+
+/** Redistributes oversized scan partitions for downstream operators, 
preserving the scan itself. */
+object RepartitionLargeBlobScan extends Rule[LogicalPlan] {
+
+  // injectOptimizerRule runs before V2 scan pushdown. The final user-provided 
optimizer batch
+  // runs after it, so register there as well when Spark initializes its 
optimizer rules.
+  def register(spark: SparkSession): Unit = {
+    val experimental = spark.experimental
+    if (!experimental.extraOptimizations.contains(this)) {
+      experimental.extraOptimizations = experimental.extraOptimizations :+ this
+    }
+  }
+
+  override def apply(plan: LogicalPlan): LogicalPlan = {
+    if (
+      !OptionUtils
+        
.getOptionString(SparkConnectorOptions.READ_REPARTITION_LARGE_BLOB_SCAN_ENABLED)
+        .toBoolean
+    ) {
+      return plan
+    }
+
+    def repartitionScan(node: LogicalPlan, relation: 
DataSourceV2ScanRelation): LogicalPlan = {
+      if (relation.isStreaming) {
+        return node
+      }
+      relation.scan match {
+        case scan: PaimonScan
+            if scan.table
+              .rowType()
+              .getFields
+              .asScala
+              .exists(field => BlobType.isBlobFileField(field.`type`())) =>
+          val partitions = scan.inputPartitions
+          val targetSize = 
BinPackingSplits.filesMaxPartitionBytes(scan.coreOptions, SQLConf.get)
+          val threshold = BigInt(targetSize) * 2
+          val sizes = partitions.map {
+            partition => partition.splits.map(split => 
BigInt(SplitUtils.splitSize(split))).sum
+          }
+          if (targetSize > 0 && sizes.exists(_ > threshold)) {
+            val numPartitions = (sizes.sum + targetSize - 1) / targetSize
+            require(
+              numPartitions <= Int.MaxValue,
+              s"Scan repartition count $numPartitions exceeds ${Int.MaxValue}; 
" +
+                "increase the scan split target size"
+            )
+            if (numPartitions <= partitions.size) {
+              node
+            } else {
+              Repartition(numPartitions.toInt, shuffle = true, node)
+            }
+          } else {
+            node
+          }
+        case _ => node
+      }
+    }
+
+    def rewrite(node: LogicalPlan): LogicalPlan = {
+      // These expressions read task-local file context, which cannot cross a 
shuffle.
+      // Conservatively leave their input subtree unchanged, including through 
aliases.
+      if (hasInputFileExpression(node)) {
+        return node
+      }
+      node match {
+        // Preserve explicit shuffles and remain idempotent for a scan with 
pruning filters.
+        case repartition: RepartitionOperation
+            if repartition.shuffle && 
scanRelation(repartition.child).isDefined =>
+          repartition
+        case filter: Filter if 
filter.condition.exists(_.isInstanceOf[DynamicPruning]) =>
+          // DataSourceV2Strategy must still extract DPP from this scan 
operation. If the
+          // operation cannot be extracted, leave it intact instead of 
inserting below it.
+          scanRelation(filter) match {
+            case Some(relation) if !filter.exists(hasInputFileExpression) =>
+              repartitionScan(filter, relation)
+            case _ => filter
+          }
+        case relation: DataSourceV2ScanRelation => repartitionScan(relation, 
relation)
+        case other => other.mapChildren(rewrite)
+      }
+    }
+
+    rewrite(plan)
+  }
+
+  private def hasInputFileExpression(plan: LogicalPlan): Boolean = {
+    plan.expressions.exists(_.exists {
+      case _: InputFileName | _: InputFileBlockStart | _: InputFileBlockLength 
=> true
+      case _ => false
+    })
+  }
+
+  private def scanRelation(plan: LogicalPlan): 
Option[DataSourceV2ScanRelation] = plan match {
+    case PhysicalOperation(_, _, relation: DataSourceV2ScanRelation) => 
Some(relation)
+    case _ => None
+  }
+}
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/extensions/PaimonSparkSessionExtensions.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/extensions/PaimonSparkSessionExtensions.scala
index 504c5ccae7..e7eb99d31b 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/extensions/PaimonSparkSessionExtensions.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/extensions/PaimonSparkSessionExtensions.scala
@@ -19,7 +19,7 @@
 package org.apache.paimon.spark.extensions
 
 import org.apache.paimon.spark.catalyst.analysis.{PaimonAnalysis, 
PaimonDeleteTable, PaimonFunctionResolver, PaimonIncompatibleResolutionRules, 
PaimonMergeInto, PaimonPostHocResolutionRules, PaimonProcedureResolver, 
PaimonUpdateTable, PaimonViewResolver, ReplacePaimonFunctions}
-import 
org.apache.paimon.spark.catalyst.optimizer.{MergePaimonScalarSubqueries, 
OptimizeMetadataOnlyDeleteFromPaimonTable, PushDownArrayPredicates, 
PushDownLateralVectorSearchFilter, RepartitionLateralVectorSearchInput}
+import 
org.apache.paimon.spark.catalyst.optimizer.{MergePaimonScalarSubqueries, 
OptimizeMetadataOnlyDeleteFromPaimonTable, PushDownArrayPredicates, 
PushDownLateralVectorSearchFilter, RepartitionLargeBlobScan, 
RepartitionLateralVectorSearchInput}
 import 
org.apache.paimon.spark.catalyst.plans.logical.PaimonTableValuedFunctions
 import org.apache.paimon.spark.commands.BucketExpression
 import org.apache.paimon.spark.execution.{OldCompatibleStrategy, 
PaimonStrategy}
@@ -108,6 +108,12 @@ class PaimonSparkSessionExtensions extends 
(SparkSessionExtensions => Unit) {
     extensions.injectOptimizerRule(_ => RepartitionLateralVectorSearchInput)
     extensions.injectOptimizerRule(_ => PushDownLateralVectorSearchFilter)
 
+    extensions.injectOptimizerRule {
+      spark =>
+        RepartitionLargeBlobScan.register(spark)
+        RepartitionLargeBlobScan
+    }
+
     // planner extensions
     extensions.injectPlannerStrategy(spark => PaimonStrategy(spark))
     // old compatible
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/RepartitionLargeBlobScanTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/RepartitionLargeBlobScanTest.scala
new file mode 100644
index 0000000000..3376ae7413
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/RepartitionLargeBlobScanTest.scala
@@ -0,0 +1,407 @@
+/*
+ * 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.paimon.spark
+
+import org.apache.paimon.data.BinaryRow
+import org.apache.paimon.io.DataFileMeta
+import org.apache.paimon.manifest.FileSource
+import org.apache.paimon.spark.catalyst.optimizer.RepartitionLargeBlobScan
+import org.apache.paimon.table.InnerTable
+import org.apache.paimon.table.source.{DataSplit, Split}
+import org.apache.paimon.types.{DataField, DataType, DataTypes, RowType}
+
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.catalyst.expressions.{Alias, And, 
DynamicPruningExpression, Expression, GreaterThan, InputFileBlockLength, 
InputFileBlockStart, InputFileName, IsNotNull, Literal}
+import org.apache.spark.sql.catalyst.plans.logical.{Filter, Project, 
Repartition}
+import org.apache.spark.sql.connector.catalog.{Table => ConnectorTable, 
TableCapability}
+import org.apache.spark.sql.execution.datasources.v2.{BatchScanExec, 
DataSourceV2Relation, DataSourceV2ScanRelation}
+import org.apache.spark.sql.types.{IntegerType, StructType}
+import org.mockito.Mockito.{mock, when}
+
+import java.util.Collections
+
+import scala.collection.JavaConverters._
+
+/** Rule tests with synthetic metadata and query regressions for file context 
and runtime pruning. */
+class RepartitionLargeBlobScanTest extends PaimonSparkTestBase {
+
+  private val enabledKey =
+    
s"spark.paimon.${SparkConnectorOptions.READ_REPARTITION_LARGE_BLOB_SCAN_ENABLED.key()}"
+
+  test("input-file expressions in projections and filters skip scan planning") 
{
+    withSplitConf {
+      Seq[Expression](InputFileName(), InputFileBlockStart(), 
InputFileBlockLength()).foreach {
+        expression =>
+          val scan = relation(Seq(Seq(120L)), failOnPlanning = true)
+          val projection = Project(Seq(Alias(expression, "file_info")()), scan)
+          assert(RepartitionLargeBlobScan(projection) eq projection)
+          val filter = Filter(IsNotNull(expression), scan)
+          assert(RepartitionLargeBlobScan(filter) eq filter)
+          val aliasProjection = Project(projection.output, projection)
+          assert(RepartitionLargeBlobScan(aliasProjection) == aliasProjection)
+      }
+    }
+  }
+
+  test("shuffle stays above dynamic pruning filters and remains idempotent") {
+    withSplitConf {
+      val scan = relation(Seq(Seq(120L), Seq(30L)))
+      val filter = Filter(
+        And(DynamicPruningExpression(Literal(true)), 
GreaterThan(scan.output.head, Literal(0))),
+        scan)
+      val result = RepartitionLargeBlobScan(filter).asInstanceOf[Repartition]
+      assert(result.numPartitions == 3)
+      assert(result.child eq filter)
+      assert(RepartitionLargeBlobScan(result) eq result)
+
+      val fileProjection = Project(Seq(Alias(InputFileName(), "file_name")()), 
scan)
+      val fileFilter = Filter(DynamicPruningExpression(Literal(true)), 
fileProjection)
+      assert(RepartitionLargeBlobScan(fileFilter) eq fileFilter)
+    }
+  }
+
+  test("input_file_name projection and filter preserve source file context in 
real queries") {
+    withSparkSQLConf(
+      "spark.sql.adaptive.enabled" -> "false",
+      "spark.paimon.write.use-v2-write" -> "false") {
+      withTable("file_context") {
+        sql("""CREATE TABLE file_context (id INT) TBLPROPERTIES (
+              |'row-tracking.enabled'='true', 'data-evolution.enabled'='true',
+              |'source.split.target-size'='4kb')""".stripMargin)
+        sql("INSERT INTO file_context SELECT /*+ COALESCE(1) */ CAST(id AS 
INT) FROM range(5000)")
+        sql("ALTER TABLE file_context ADD COLUMN payload BINARY COMMENT 
'__BLOB_FIELD'")
+
+        withSparkSQLConf(enabledKey -> "true") {
+          assert(sql("SELECT id FROM 
file_context").queryExecution.optimizedPlan.exists {
+            case _: Repartition => true
+            case _ => false
+          })
+        }
+        val queries = Seq(
+          "SELECT id, input_file_name(), input_file_block_start(), 
input_file_block_length() " +
+            "FROM file_context",
+          "SELECT id FROM file_context WHERE length(input_file_name()) > 0"
+        )
+        queries.zipWithIndex.foreach {
+          case (query, index) =>
+            var expected = Seq.empty[Row]
+            withSparkSQLConf(enabledKey -> "false") {
+              expected = sql(query).collect().toSeq
+            }
+            assert(expected.size == 5000)
+            if (index == 0) {
+              assert(expected.forall(_.getString(1).nonEmpty))
+            }
+            withSparkSQLConf(enabledKey -> "true") {
+              val result = sql(query)
+              
assert(!result.queryExecution.optimizedPlan.exists(_.isInstanceOf[Repartition]))
+              checkAnswer(result, expected)
+            }
+        }
+      }
+    }
+  }
+
+  test("real Blob join retains runtime partition filters below the added 
shuffle") {
+    withSparkSQLConf(
+      "spark.sql.adaptive.enabled" -> "false",
+      "spark.paimon.write.use-v2-write" -> "false",
+      "spark.sql.optimizer.dynamicPartitionPruning.enabled" -> "true",
+      "spark.sql.optimizer.dynamicPartitionPruning.useStats" -> "false",
+      "spark.sql.optimizer.dynamicPartitionPruning.reuseBroadcastOnly" -> 
"false",
+      "spark.sql.optimizer.dynamicPartitionPruning.fallbackFilterRatio" -> 
"1.0"
+    ) {
+      withTable("pruning_fact", "pruning_dim") {
+        sql("""CREATE TABLE pruning_fact (id INT, pt INT, payload BINARY) 
PARTITIONED BY (pt)
+              |TBLPROPERTIES ('row-tracking.enabled'='true', 
'data-evolution.enabled'='true',
+              |'blob-field'='payload', 
'source.split.target-size'='64kb')""".stripMargin)
+        sql("""INSERT INTO pruning_fact SELECT /*+ COALESCE(1) */
+              |CAST(id AS INT), CAST(id DIV 100 AS INT), CAST(repeat('x', 
8192) AS BINARY)
+              |FROM range(300)""".stripMargin)
+        sql("CREATE TABLE pruning_dim (pt INT, keep INT)")
+        sql("INSERT INTO pruning_dim VALUES (0, 1), (1, 0), (2, 0)")
+        val query = """SELECT /*+ BROADCAST(d) */ f.id, length(f.payload)
+                      |FROM pruning_fact f JOIN pruning_dim d ON f.pt = d.pt
+                      |WHERE d.keep = 1""".stripMargin
+        Seq(false, true).foreach {
+          enabled =>
+            withSparkSQLConf(enabledKey -> enabled.toString) {
+              val result = sql(query)
+              if (enabled) {
+                
assert(result.queryExecution.optimizedPlan.exists(_.isInstanceOf[Repartition]))
+              }
+              val factScan = result.queryExecution.executedPlan.collectFirst {
+                case scan: BatchScanExec if scan.output.exists(_.name == 
"payload") => scan
+              }.get
+              assert(factScan.runtimeFilters.nonEmpty, 
result.queryExecution.executedPlan.toString)
+              checkAnswer(result, (0 until 100).map(id => Row(id, 8192)))
+              assert(factScan.metrics("numOutputRows").value == 100)
+            }
+        }
+      }
+    }
+  }
+
+  test("non-Blob tables are skipped without planning scan partitions") {
+    withSplitConf {
+      Seq(None, Some(DataTypes.BYTES())).foreach {
+        blobType =>
+          val scan = relation(Seq(Seq(120L)), failOnPlanning = true, blobType 
= blobType)
+          assert(RepartitionLargeBlobScan(scan) eq scan)
+      }
+    }
+  }
+
+  test("Blob tables remain eligible after Blob columns are pruned") {
+    withSplitConf {
+      Seq(
+        DataTypes.BLOB(),
+        DataTypes.ARRAY(DataTypes.BLOB()),
+        DataTypes.MAP(DataTypes.STRING(), DataTypes.BLOB())).foreach {
+        blobType =>
+          val scan = relation(Seq(Seq(120L), Seq(30L)), blobType = 
Some(blobType))
+          assert(scan.scan.readSchema().fieldNames.toSeq == Seq("a"))
+          
assert(RepartitionLargeBlobScan(scan).asInstanceOf[Repartition].numPartitions 
== 3)
+      }
+    }
+  }
+
+  test("disabled by default without planning scan partitions") {
+    assert(spark.conf.getOption(enabledKey).isEmpty)
+    val scan = relation(Seq(Seq(120L)), failOnPlanning = true)
+    assert(RepartitionLargeBlobScan(scan) eq scan)
+  }
+
+  test("explicitly disabling the rule avoids planning scan partitions") {
+    withSplitConf {
+      withSparkSQLConf(enabledKey -> "false") {
+        val scan = relation(Seq(Seq(120L)), failOnPlanning = true)
+        assert(RepartitionLargeBlobScan(scan) eq scan)
+      }
+      val enabled = relation(Seq(Seq(120L), Seq(30L)))
+      
assert(RepartitionLargeBlobScan(enabled).asInstanceOf[Repartition].numPartitions
 == 3)
+    }
+  }
+
+  test("partition size includes all splits and exact threshold does not 
trigger") {
+    withSplitConf {
+      val fits = relation(Seq(Seq(50L, 50L), Seq(80L)))
+      assert(RepartitionLargeBlobScan(fits) eq fits)
+      val oversized = relation(Seq(Seq(60L, 60L), Seq(30L)))
+      val result = 
RepartitionLargeBlobScan(oversized).asInstanceOf[Repartition]
+      assert(result.numPartitions == 3)
+      assert(result.shuffle)
+      assert(result.child eq oversized)
+      assert(RepartitionLargeBlobScan(result) eq result)
+    }
+  }
+
+  test("repartition count includes small partitions and rounds up") {
+    withSplitConf {
+      val result = RepartitionLargeBlobScan(relation(Seq(Seq(101L), Seq(25L), 
Seq(25L))))
+      assert(result.asInstanceOf[Repartition].numPartitions == 4)
+    }
+  }
+
+  test("minimum partition count does not lower the repartition threshold") {
+    withSplitConf {
+      withSparkSQLConf(
+        "spark.sql.files.maxPartitionBytes" -> "1000b",
+        "spark.sql.files.minPartitionNum" -> "4") {
+        val scan = relation(Seq(Seq(80L), Seq(20L)))
+        assert(RepartitionLargeBlobScan(scan) eq scan)
+      }
+    }
+  }
+
+  test(
+    "skip shuffle when a large Blob partition and small partitions already 
have more parallelism") {
+    withSplitConf {
+      val scan = relation(
+        Seq(Seq(256L * 1024)) ++ Seq.fill(20)(Seq(1L)),
+        Map("source.split.target-size" -> "64kb"))
+      // The oversized partition triggers the size check, but ceil(262164 / 
65536) = 5
+      // would reduce the existing 21 input partitions. Preserve the scan 
without a shuffle.
+      assert(RepartitionLargeBlobScan(scan) eq scan)
+    }
+  }
+
+  test("skip shuffle when computed partition count equals existing input 
partition count") {
+    withSplitConf {
+      val scan = relation(
+        Seq(Seq(256L * 1024)) ++ Seq.fill(4)(Seq(1L)),
+        Map("source.split.target-size" -> "64kb"))
+      assert(RepartitionLargeBlobScan(scan) eq scan)
+    }
+  }
+
+  test("open file cost does not change the threshold or shuffle partition 
count") {
+    withSplitConf {
+      withSparkSQLConf(
+        "spark.sql.files.openCostInBytes" -> "1000b",
+        "spark.sql.files.minPartitionNum" -> "10") {
+        val fits = relation(Seq(Seq(80L), Seq(20L)))
+        assert(RepartitionLargeBlobScan(fits) eq fits)
+        val result = RepartitionLargeBlobScan(relation(Seq(Seq(101L), 
Seq(50L))))
+        assert(result.asInstanceOf[Repartition].numPartitions == 4)
+      }
+    }
+  }
+
+  test("Paimon split settings take precedence over Spark file settings") {
+    withSplitConf {
+      val scan = relation(
+        Seq(Seq(80L), Seq(20L)),
+        Map("source.split.target-size" -> "25b", "source.split.open-file-cost" 
-> "0b"))
+      
assert(RepartitionLargeBlobScan(scan).asInstanceOf[Repartition].numPartitions 
== 4)
+    }
+  }
+
+  test("preserve an existing shuffle and leave empty scans unchanged") {
+    withSplitConf {
+      val existing = Repartition(7, shuffle = true, relation(Seq(Seq(120L))))
+      assert(RepartitionLargeBlobScan(existing) eq existing)
+      val empty = relation(Seq.empty)
+      assert(RepartitionLargeBlobScan(empty) eq empty)
+    }
+  }
+
+  test("doubling the threshold does not overflow Long") {
+    withSplitConf {
+      withSparkSQLConf("spark.sql.files.maxPartitionBytes" -> 
s"${Long.MaxValue}b") {
+        val fits = relation(Seq(Seq(Long.MaxValue, Long.MaxValue)))
+        assert(RepartitionLargeBlobScan(fits) eq fits)
+        val oversized = relation(Seq(Seq(Long.MaxValue, Long.MaxValue, 
Long.MaxValue)))
+        
assert(RepartitionLargeBlobScan(oversized).asInstanceOf[Repartition].numPartitions
 == 3)
+      }
+    }
+  }
+
+  test("reject a partition count that does not fit Spark's integer limit") {
+    withSplitConf {
+      withSparkSQLConf("spark.sql.files.maxPartitionBytes" -> "1b") {
+        val error = intercept[IllegalArgumentException] {
+          RepartitionLargeBlobScan(relation(Seq(Seq(Long.MaxValue))))
+        }
+        assert(error.getMessage.contains("exceeds"))
+      }
+    }
+  }
+
+  test("rule is registered once in an optimizer batch after scan pushdown") {
+    val batches = spark.sessionState.optimizer.batches
+    val pushdownIndex = batches.indexWhere(_.name == "Early Filter and 
Projection Push-Down")
+    val lateIndex = batches.indexWhere {
+      batch =>
+        batch.rules.contains(RepartitionLargeBlobScan) &&
+        batch.name == "User Provided Optimizers"
+    }
+    assert(pushdownIndex >= 0)
+    assert(lateIndex > pushdownIndex)
+    RepartitionLargeBlobScan.register(spark)
+    RepartitionLargeBlobScan.register(spark)
+    assert(spark.experimental.extraOptimizations.count(_ == 
RepartitionLargeBlobScan) == 1)
+    withSplitConf {
+      val scan = relation(Seq(Seq(120L), Seq(30L)))
+      val result = batches(lateIndex).rules.foldLeft(
+        scan: org.apache.spark.sql.catalyst.plans.logical.LogicalPlan)((plan, 
rule) => rule(plan))
+      assert(result.asInstanceOf[Repartition].numPartitions == 3)
+    }
+  }
+
+  test("non-Paimon plans are unchanged") {
+    withSplitConf {
+      val plan = spark.range(10).queryExecution.optimizedPlan
+      assert(RepartitionLargeBlobScan(plan) eq plan)
+    }
+  }
+
+  private def withSplitConf(f: => Unit): Unit = {
+    withSparkSQLConf(
+      enabledKey -> "true",
+      "spark.sql.files.maxPartitionBytes" -> "50b",
+      "spark.sql.files.minPartitionNum" -> "1",
+      "spark.sql.files.openCostInBytes" -> "0b")(f)
+  }
+
+  private def relation(
+      partitionFileSizes: Seq[Seq[Long]],
+      options: Map[String, String] = Map.empty,
+      failOnPlanning: Boolean = false,
+      blobType: Option[DataType] = Some(DataTypes.BLOB())): 
DataSourceV2ScanRelation = {
+    val schema = new StructType().add("a", IntegerType)
+    val table = mock(classOf[InnerTable])
+    when(table.options()).thenReturn(options.asJava)
+    val fields = Seq(new DataField(0, "a", DataTypes.INT())) ++
+      blobType.map(dataType => new DataField(1, "payload", dataType))
+    when(table.rowType()).thenReturn(new RowType(fields.asJava))
+    when(table.partitionKeys()).thenReturn(Collections.emptyList[String]())
+    when(table.primaryKeys()).thenReturn(Collections.emptyList[String]())
+
+    val partitions = partitionFileSizes.zipWithIndex.map {
+      case (fileSizes, partitionIndex) =>
+        val splits = fileSizes.zipWithIndex.map {
+          case (size, fileIndex) =>
+            val file = DataFileMeta.forAppend(
+              s"$partitionIndex-$fileIndex.parquet",
+              size,
+              1,
+              null,
+              0,
+              0,
+              1,
+              Collections.emptyList[String](),
+              null,
+              FileSource.APPEND,
+              null,
+              null,
+              null,
+              null)
+            DataSplit
+              .builder()
+              .withSnapshot(1)
+              .withPartition(BinaryRow.EMPTY_ROW)
+              .withBucket(0)
+              .withBucketPath("test")
+              .withDataFiles(Collections.singletonList(file))
+              .rawConvertible(false)
+              .build()
+        }
+        PaimonInputPartition(splits)
+    }
+    val scan = new PaimonScan(table, schema, Seq.empty, Seq.empty, None, None, 
None) {
+      override protected def getInputSplits: Array[Split] = {
+        assert(!failOnPlanning, "Skipped scans must not plan scan splits")
+        partitions.flatMap(_.splits).toArray
+      }
+      override protected def getInputPartitions(splits: Array[Split]): 
Seq[PaimonInputPartition] = {
+        assert(!failOnPlanning, "Skipped scans must not plan scan partitions")
+        partitions
+      }
+    }
+    val sparkTable = new ConnectorTable {
+      override def name(): String = "test_scan"
+      override def schema(): StructType = scan.requiredSchema
+      override def capabilities(): java.util.Set[TableCapability] =
+        Collections.emptySet[TableCapability]()
+    }
+    val source = DataSourceV2Relation.create(sparkTable, None, None)
+    DataSourceV2ScanRelation(source, scan, source.output)
+  }
+}

Reply via email to