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

parthchandra pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git


The following commit(s) were added to refs/heads/main by this push:
     new b7ad0ba277 feat: core SPI for contrib leaf scans 
(CometScanWithPlanData) [Delta contrib split, part 1] (#4700)
b7ad0ba277 is described below

commit b7ad0ba277409e88f76a993c83da95e7b3b5775d
Author: Scott Schenkein <[email protected]>
AuthorDate: Fri Jul 10 17:51:06 2026 -0400

    feat: core SPI for contrib leaf scans (CometScanWithPlanData) [Delta 
contrib split, part 1] (#4700)
    
    * feat: core SPI for contrib leaf scans (CometScanWithPlanData)
    
    Introduce a small extension contract so out-of-tree Comet contrib leaf scans
    (Delta, and future Hudi/etc.) can participate in native planning without 
core
    holding a compile-time reference to them -- mirroring the Iceberg-precedent 
of
    keeping the data-source-specific code at the edge.
    
    What this adds:
    - `trait CometScanWithPlanData` (`sourceKey` / `commonData` / 
`perPartitionData`,
      plus optional `dynamicPruningFilters` / `withDynamicPruningFilters` for 
scans
      whose DPP filters live in a @transient field). `CometNativeScanExec` now 
mixes
      it in.
    - `CometNativeExec.foreachUntilCometInput` matches `case _: CometLeafExec` 
(a
      strict superset of the previous fixed scan enumeration -- all built-in 
leaf
      scans already extend `CometLeafExec`), so any leaf Comet exec is 
recognised as
      an input boundary.
    - `PlanDataInjector.findAllPlanData` collects per-partition planning data 
via the
      trait instead of a hardcoded `CometNativeScanExec` match.
    - `PlanDataInjector`'s registry gains one reflective 
`DeltaPlanDataInjector$`
      slot, appended only when a contrib bundled it (`-Pcontrib-delta`). Default
      builds get a `ClassNotFoundException` -> `None` and an unchanged 
injectors list,
      so there is zero contrib surface at runtime.
    - `CometPlanAdaptiveDynamicPruningFilters` rewrites AQE DPP filters in 
place for
      trait scans whose filters can't survive `makeCopy` (#3510).
    
    Inert by construction: with no contrib on the classpath this is behavior-
    preserving (the leaf match is a superset; the trait match catches the same
    `CometNativeScanExec`; the reflective slot resolves to nothing).
    
    Tests: `CometScanWithPlanDataSuite` (trait-contract defaults + 
reflective-slot
    graceful absence). Verified `CometJoinSuite` (native scan fusion / DPP) 
stays
    green.
    
    First unit of the Delta-contrib PR split (tracking: #4366).
    
    * refactor: address review feedback on contrib leaf-scan SPI
    
    parthchandra:
    - Discover contrib PlanDataInjectors via java.util.ServiceLoader
      (META-INF/services) instead of a hardcoded reflective 
DeltaPlanDataInjector$
      slot, so contribs are discoverable without editing core.
    - Catch via scala.util.control.NonFatal (covers ServiceConfigurationError).
    - findAllPlanData's generic trait arm now mirrors the Iceberg arm: empty
      common/per-partition data contributes nothing instead of an empty entry
      that would fail downstream injection.
    - Add a stub CometLeafExec with CometScanWithPlanData wired through
      findAllPlanData, confirming the new path works independently of the
      existing scan classes; plus a ServiceLoader discovery test.
    
    andygrove:
    - Make CometScanWithPlanData a self-type `{ self: CometLeafExec => }`, so
      "is a leaf scan" is a compile-time requirement and findAllPlanData drives
      the subquery lifecycle with no silent "not a leaf" fallback.
    - Mirror the TODO(#3510) marker on withDynamicPruningFilters.
    - Pre-emptively exclude CometScanWithPlanData from the non-Comet DPP arm in
      CometPlanAdaptiveDynamicPruningFilters so a future trait scan with a 
wrapped
      SAB but empty dynamicPruningFilters isn't misrouted.
    
    Also relocate findAllPlanData from CometNativeExec into the PlanDataInjector
    object (it uses no instance state), which fixes the now-correct
    PlanDataInjector.findAllPlanData scaladoc reference and makes it 
unit-testable.
    
    CometScanWithPlanDataSuite 5/5 on Spark 4.1/2.13; spark module also compiles
    clean under Scala 2.12 (Spark 3.4).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01BtErWgRQKCDRAg8Mk6qR4G
    
    * docs: fix findAllPlanData scaladoc references after relocation
    
    findAllPlanData moved from CometNativeExec into the PlanDataInjector object;
    update the now-stale `CometNativeExec.findAllPlanData` references in the
    CometNativeScanExec and CometIcebergNativeScanExec doc comments to
    `PlanDataInjector.findAllPlanData`.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---
 .github/workflows/pr_build_linux.yml               |   1 +
 .github/workflows/pr_build_macos.yml               |   1 +
 .../CometPlanAdaptiveDynamicPruningFilters.scala   |  13 ++
 .../sql/comet/CometIcebergNativeScanExec.scala     |  10 +-
 .../spark/sql/comet/CometNativeScanExec.scala      |   5 +-
 .../org/apache/spark/sql/comet/operators.scala     | 223 ++++++++++++++-------
 .../sql/comet/CometScanWithPlanDataSuite.scala     | 187 +++++++++++++++++
 7 files changed, 365 insertions(+), 75 deletions(-)

diff --git a/.github/workflows/pr_build_linux.yml 
b/.github/workflows/pr_build_linux.yml
index 26281316f0..6d39ea6b64 100644
--- a/.github/workflows/pr_build_linux.yml
+++ b/.github/workflows/pr_build_linux.yml
@@ -360,6 +360,7 @@ jobs:
               org.apache.spark.sql.comet.CometShuffleFallbackStickinessSuite
               org.apache.spark.sql.comet.PlanDataInjectorSuite
               org.apache.spark.sql.comet.CometDecimalArithmeticViewSuite
+              org.apache.spark.sql.comet.CometScanWithPlanDataSuite
               org.apache.spark.sql.comet.util.UtilsSuite
               org.apache.comet.objectstore.NativeConfigSuite
               org.apache.spark.sql.CometToPrettyStringSuite
diff --git a/.github/workflows/pr_build_macos.yml 
b/.github/workflows/pr_build_macos.yml
index c6dd4978f7..e409da4b24 100644
--- a/.github/workflows/pr_build_macos.yml
+++ b/.github/workflows/pr_build_macos.yml
@@ -176,6 +176,7 @@ jobs:
               org.apache.spark.sql.comet.CometShuffleFallbackStickinessSuite
               org.apache.spark.sql.comet.PlanDataInjectorSuite
               org.apache.spark.sql.comet.CometDecimalArithmeticViewSuite
+              org.apache.spark.sql.comet.CometScanWithPlanDataSuite
               org.apache.spark.sql.comet.util.UtilsSuite
               org.apache.comet.objectstore.NativeConfigSuite
               org.apache.spark.sql.CometToPrettyStringSuite
diff --git 
a/spark/src/main/scala/org/apache/comet/rules/CometPlanAdaptiveDynamicPruningFilters.scala
 
b/spark/src/main/scala/org/apache/comet/rules/CometPlanAdaptiveDynamicPruningFilters.scala
index 217f8bc314..8d97651078 100644
--- 
a/spark/src/main/scala/org/apache/comet/rules/CometPlanAdaptiveDynamicPruningFilters.scala
+++ 
b/spark/src/main/scala/org/apache/comet/rules/CometPlanAdaptiveDynamicPruningFilters.scala
@@ -83,9 +83,22 @@ case object CometPlanAdaptiveDynamicPruningFilters
           if icebergScan.runtimeFilters.exists(hasCometSAB) =>
         logDebug("Converting AQE DPP for CometIcebergNativeScanExec")
         convertIcebergScanDPP(icebergScan, plan)
+      // Comet scans whose DPP filters live in a @transient field (the 
contrib's
+      // CometDeltaNativeScanExec). transformExpressions/makeCopy can't rewrite
+      // them, and a rewritten copy is orphaned when the enclosing native block
+      // is rebuilt (#3510). The scan's `withDynamicPruningFilters` installs 
the
+      // rewrite in place and returns `this`, so it lands on the executing
+      // instance.
+      case p: CometScanWithPlanData if 
p.dynamicPruningFilters.exists(hasCometSAB) =>
+        logDebug(s"Converting AQE DPP for ${p.getClass.getSimpleName} in 
place")
+        p.withDynamicPruningFilters(p.dynamicPruningFilters.map(f => 
convertFilter(f, plan)))
       case p: SparkPlan
           if !p.isInstanceOf[CometNativeScanExec]
             && !p.isInstanceOf[CometIcebergNativeScanExec]
+            // Contrib trait scans handle their own DPP in the arm above. 
Exclude them here too so
+            // a trait scan with a wrapped SAB but empty 
`dynamicPruningFilters` isn't misrouted to
+            // the non-Comet path (which mutates expressions in place via 
makeCopy).
+            && !p.isInstanceOf[CometScanWithPlanData]
             && hasWrappedSAB(p) =>
         logDebug(s"Converting AQE DPP for non-Comet node: ${p.nodeName}")
         convertNonCometNodeDPP(p, plan)
diff --git 
a/spark/src/main/scala/org/apache/spark/sql/comet/CometIcebergNativeScanExec.scala
 
b/spark/src/main/scala/org/apache/spark/sql/comet/CometIcebergNativeScanExec.scala
index b262494833..95cf2fc9fd 100644
--- 
a/spark/src/main/scala/org/apache/spark/sql/comet/CometIcebergNativeScanExec.scala
+++ 
b/spark/src/main/scala/org/apache/spark/sql/comet/CometIcebergNativeScanExec.scala
@@ -51,7 +51,7 @@ import org.apache.comet.serde.operator.CometIcebergNativeScan
  * `expressions` walk picks up the contained 
`DynamicPruningExpression(InSubqueryExec(...))`, and
  * the standard `prepare -> prepareSubqueries -> waitForSubqueries` lifecycle 
resolves it. The
  * lifecycle is invoked via `CometLeafExec.ensureSubqueriesResolved`, called 
from
- * `CometNativeExec.findAllPlanData` before `commonData` is read.
+ * `PlanDataInjector.findAllPlanData` before `commonData` is read.
  */
 case class CometIcebergNativeScanExec(
     override val nativeOp: Operator,
@@ -70,12 +70,12 @@ case class CometIcebergNativeScanExec(
 
   /**
    * Lazy partition serialization, deferred until execution time. Triggered 
from `commonData` /
-   * `perPartitionData` (via `CometNativeExec.findAllPlanData`) and from 
`LazyIcebergMetric.value`
-   * (via Iceberg planning metrics). Lazy val semantics ensure single 
evaluation across entry
-   * points.
+   * `perPartitionData` (via `PlanDataInjector.findAllPlanData`) and from
+   * `LazyIcebergMetric.value` (via Iceberg planning metrics). Lazy val 
semantics ensure single
+   * evaluation across entry points.
    *
    * DPP InSubqueryExec values must already be resolved by the time this lazy 
val runs.
-   * `CometNativeExec.findAllPlanData` calls `ensureSubqueriesResolved` (which 
invokes Spark's
+   * `PlanDataInjector.findAllPlanData` calls `ensureSubqueriesResolved` 
(which invokes Spark's
    * `prepare -> waitForSubqueries`) before reading `commonData`. The 
`serializePartitions` call
    * below reads `originalPlan.runtimeFilters` indirectly through `inputRDD -> 
filteredPartitions`
    * and applies the resolved values to Iceberg's runtime filtering. 
`originalPlan.runtimeFilters`
diff --git 
a/spark/src/main/scala/org/apache/spark/sql/comet/CometNativeScanExec.scala 
b/spark/src/main/scala/org/apache/spark/sql/comet/CometNativeScanExec.scala
index ff52c6a778..5b577f41f4 100644
--- a/spark/src/main/scala/org/apache/spark/sql/comet/CometNativeScanExec.scala
+++ b/spark/src/main/scala/org/apache/spark/sql/comet/CometNativeScanExec.scala
@@ -71,7 +71,8 @@ case class CometNativeScanExec(
     sourceKey: String) // Key for PlanDataInjector to match common+partition 
data at runtime
     extends CometLeafExec
     with DataSourceScanExec
-    with ShimStreamSourceAwareSparkPlan {
+    with ShimStreamSourceAwareSparkPlan
+    with CometScanWithPlanData {
 
   override lazy val metadata: Map[String, String] =
     if (originalPlan != null) originalPlan.metadata else Map.empty
@@ -159,7 +160,7 @@ case class CometNativeScanExec(
     // Outer partitionFilters (wrapper) DPP is resolved by Spark's standard
     // prepare -> waitForSubqueries lifecycle, triggered explicitly via
     // CometLeafExec.ensureSubqueriesResolved called from
-    // CometNativeExec.findAllPlanData before commonData is read.
+    // PlanDataInjector.findAllPlanData before commonData is read.
     //
     // Inner scan.partitionFilters holds a SEPARATE InSubqueryExec instance 
that
     // Spark's expressions walk does not see (scan is @transient and not a 
sibling
diff --git a/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala 
b/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
index a8c674ca30..4c9ca07bdf 100644
--- a/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
+++ b/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
@@ -20,13 +20,16 @@
 package org.apache.spark.sql.comet
 
 import java.util.Locale
+import java.util.ServiceLoader
 
 import scala.collection.mutable
 import scala.collection.mutable.ArrayBuffer
 import scala.jdk.CollectionConverters._
+import scala.util.control.NonFatal
 
 import org.apache.spark.{Partition, TaskContext}
 import org.apache.spark.broadcast.Broadcast
+import org.apache.spark.internal.Logging
 import org.apache.spark.rdd.RDD
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.expressions.{Ascending, Attribute, 
AttributeSet, Expression, ExpressionSet, Generator, NamedExpression, SortOrder}
@@ -87,14 +90,34 @@ private[comet] trait PlanDataInjector {
 /**
  * Registry and utilities for injecting per-partition planning data into 
operator trees.
  */
-private[comet] object PlanDataInjector {
-
-  // Registry of injectors for different operator types
-  private val injectors: Seq[PlanDataInjector] = Seq(
-    IcebergPlanDataInjector,
-    NativeScanPlanDataInjector
-    // Future: DeltaPlanDataInjector, HudiPlanDataInjector, etc.
-  )
+private[comet] object PlanDataInjector extends Logging {
+
+  // Registry of injectors for different operator types. The built-in 
injectors live in core.
+  // Out-of-tree contribs (e.g. contrib-delta's `DeltaPlanDataInjector`) are 
discovered via the
+  // standard JDK `ServiceLoader`: a contrib ships a
+  // `META-INF/services/org.apache.spark.sql.comet.PlanDataInjector` resource 
naming its
+  // implementation, so it joins the registry without core holding any 
compile-time reference or
+  // contrib-specific code. Default builds carry no such service file, so 
discovery yields nothing
+  // and the registry is exactly the built-ins -- zero contrib surface at 
runtime.
+  private[comet] val injectors: Seq[PlanDataInjector] = {
+    val builtin: Seq[PlanDataInjector] = Seq(IcebergPlanDataInjector, 
NativeScanPlanDataInjector)
+    val discovered: Seq[PlanDataInjector] =
+      try {
+        ServiceLoader.load(classOf[PlanDataInjector], 
getClass.getClassLoader).asScala.toSeq
+      } catch {
+        // A misbuilt contrib jar -- a malformed service file, or a listed 
provider that can't be
+        // instantiated -- surfaces as ServiceConfigurationError while the 
iterator is forced.
+        // Warn so it's diagnosable, then continue with the built-ins so the 
planner stays alive.
+        // NonFatal covers ServiceConfigurationError (it is not a 
LinkageError).
+        case NonFatal(e) =>
+          logWarning(
+            "Failed to load contrib PlanDataInjector services; " +
+              "continuing with built-in injectors only",
+            e)
+          Seq.empty
+      }
+    builtin ++ discovered
+  }
 
   // O(1) lookup by op kind: most operators in any tree don't match any 
injector, so the per-op
   // `for (injector <- injectors if injector.canInject(op))` walk was paying 
N*M canInject calls
@@ -153,6 +176,73 @@ private[comet] object PlanDataInjector {
     codedOutput.checkNoSpaceLeft()
     bytes
   }
+
+  /**
+   * Find all plan nodes with per-partition planning data in the plan tree. 
Returns two maps keyed
+   * by a unique identifier: one for common data (shared across partitions) 
and one for
+   * per-partition data.
+   *
+   * Recognises Iceberg scans (keyed by metadata_location) plus any leaf scan 
that surfaces its
+   * data via the [[CometScanWithPlanData]] trait (`CometNativeScanExec` and 
out-of-tree contrib
+   * scans such as the Delta contrib's `CometDeltaNativeScanExec`).
+   *
+   * Stops at stage boundaries (shuffle exchanges, etc.) because partition 
indices are only valid
+   * within the same stage.
+   *
+   * @return
+   *   (commonByKey, perPartitionByKey) - common data is shared, per-partition 
varies
+   */
+  private[comet] def findAllPlanData(
+      plan: SparkPlan): (Map[String, Array[Byte]], Map[String, 
Array[Array[Byte]]]) = {
+    plan match {
+      case iceberg: CometIcebergNativeScanExec =>
+        // Trigger Spark's standard prepare -> waitForSubqueries lifecycle so 
DPP
+        // InSubqueryExec values are resolved before commonData is read. 
Without this,
+        // the parent CometNativeExec.executeQuery flow never invokes the 
scan's
+        // executeQuery, leaving DPP unresolved and forcing a sync-on-this 
await inside
+        // the serializedPartitionData lazy val initializer (a known deadlock 
surface).
+        iceberg.ensureSubqueriesResolved()
+        if (iceberg.commonData.nonEmpty && iceberg.perPartitionData.nonEmpty) {
+          (
+            Map(iceberg.metadataLocation -> iceberg.commonData),
+            Map(iceberg.metadataLocation -> iceberg.perPartitionData))
+        } else {
+          (Map.empty, Map.empty)
+        }
+
+      // Generic path for leaf scans that surface planning data via the
+      // `CometScanWithPlanData` trait. Catches `CometNativeScanExec` and any 
contrib
+      // leaf scan (e.g. the Delta contrib's `CometDeltaNativeScanExec`) 
without
+      // requiring core to compile-time reference contrib classes. The trait's
+      // `self: CometLeafExec` self-type guarantees this is also a 
`CometLeafExec`, so
+      // the compound pattern always matches and we can drive the subquery 
lifecycle
+      // directly -- there is no silent "not a leaf" skip.
+      case s: CometLeafExec with CometScanWithPlanData =>
+        s.ensureSubqueriesResolved()
+        if (s.commonData.nonEmpty && s.perPartitionData.nonEmpty) {
+          (Map(s.sourceKey -> s.commonData), Map(s.sourceKey -> 
s.perPartitionData))
+        } else {
+          (Map.empty, Map.empty)
+        }
+
+      // Broadcast stages are boundaries - don't collect per-partition data 
from inside them.
+      // After DPP filtering, broadcast scans may have different partition 
counts than the
+      // probe side, causing ArrayIndexOutOfBoundsException in 
CometExecRDD.getPartitions.
+      case _: BroadcastQueryStageExec | _: CometBroadcastExchangeExec =>
+        (Map.empty, Map.empty)
+
+      // Stage boundaries - stop searching (partition indices won't align 
after these)
+      case _: ShuffleQueryStageExec | _: AQEShuffleReadExec | _: 
CometShuffleExchangeExec |
+          _: CometUnionExec | _: CometTakeOrderedAndProjectExec | _: 
CometCoalesceExec |
+          _: ReusedExchangeExec | _: CometSparkToColumnarExec =>
+        (Map.empty, Map.empty)
+
+      // Continue searching through other operators, combining results from 
all children
+      case _ =>
+        val results = plan.children.map(findAllPlanData)
+        (results.flatMap(_._1).toMap, results.flatMap(_._2).toMap)
+    }
+  }
 }
 
 /**
@@ -539,7 +629,7 @@ abstract class CometNativeExec extends CometExec {
       }
 
     // Find planning data within this stage (stops at shuffle boundaries).
-    val (commonByKey, perPartitionByKey) = findAllPlanData(this)
+    val (commonByKey, perPartitionByKey) = 
PlanDataInjector.findAllPlanData(this)
 
     // Collect the input batches from the child operators. Non-shuffle inputs 
become
     // RDD[ArrowArrayStream] (one stream per partition, exported via the C 
Stream Interface
@@ -718,8 +808,16 @@ abstract class CometNativeExec extends CometExec {
    */
   def foreachUntilCometInput(plan: SparkPlan)(func: SparkPlan => Unit): Unit = 
{
     plan match {
-      case _: CometNativeScanExec | _: CometScanExec | _: CometBatchScanExec |
-          _: CometIcebergNativeScanExec | _: CometCsvNativeScanExec | _: 
ShuffleQueryStageExec |
+      // Match `CometLeafExec` first so contrib leaf scans (e.g. the Delta
+      // contrib's `CometDeltaNativeScanExec`) are recognised as input 
boundaries
+      // without requiring a core compile-time reference to the contrib class.
+      // All built-in leaf scans (`CometNativeScanExec`, 
`CometIcebergNativeScanExec`,
+      // `CometCsvNativeScanExec`) also extend `CometLeafExec`, so this is a
+      // strict superset of the previous enumeration -- it just generalises the
+      // input-boundary concept from "this fixed list" to "any leaf Comet 
exec".
+      case _: CometLeafExec =>
+        func(plan)
+      case _: CometScanExec | _: CometBatchScanExec | _: ShuffleQueryStageExec 
|
           _: AQEShuffleReadExec | _: CometShuffleExchangeExec | _: 
CometUnionExec |
           _: CometTakeOrderedAndProjectExec | _: CometCoalesceExec | _: 
ReusedExchangeExec |
           _: CometBroadcastExchangeExec | _: BroadcastQueryStageExec |
@@ -754,63 +852,6 @@ abstract class CometNativeExec extends CometExec {
     indices.toSet
   }
 
-  /**
-   * Find all plan nodes with per-partition planning data in the plan tree. 
Returns two maps keyed
-   * by a unique identifier: one for common data (shared across partitions) 
and one for
-   * per-partition data.
-   *
-   * Currently supports Iceberg scans (keyed by metadata_location). Additional 
scan types can be
-   * added by extending this method.
-   *
-   * Stops at stage boundaries (shuffle exchanges, etc.) because partition 
indices are only valid
-   * within the same stage.
-   *
-   * @return
-   *   (commonByKey, perPartitionByKey) - common data is shared, per-partition 
varies
-   */
-  private def findAllPlanData(
-      plan: SparkPlan): (Map[String, Array[Byte]], Map[String, 
Array[Array[Byte]]]) = {
-    plan match {
-      case iceberg: CometIcebergNativeScanExec =>
-        // Trigger Spark's standard prepare -> waitForSubqueries lifecycle so 
DPP
-        // InSubqueryExec values are resolved before commonData is read. 
Without this,
-        // the parent CometNativeExec.executeQuery flow never invokes the 
scan's
-        // executeQuery, leaving DPP unresolved and forcing a sync-on-this 
await inside
-        // the serializedPartitionData lazy val initializer (a known deadlock 
surface).
-        iceberg.ensureSubqueriesResolved()
-        if (iceberg.commonData.nonEmpty && iceberg.perPartitionData.nonEmpty) {
-          (
-            Map(iceberg.metadataLocation -> iceberg.commonData),
-            Map(iceberg.metadataLocation -> iceberg.perPartitionData))
-        } else {
-          (Map.empty, Map.empty)
-        }
-
-      case nativeScan: CometNativeScanExec =>
-        nativeScan.ensureSubqueriesResolved()
-        (
-          Map(nativeScan.sourceKey -> nativeScan.commonData),
-          Map(nativeScan.sourceKey -> nativeScan.perPartitionData))
-
-      // Broadcast stages are boundaries - don't collect per-partition data 
from inside them.
-      // After DPP filtering, broadcast scans may have different partition 
counts than the
-      // probe side, causing ArrayIndexOutOfBoundsException in 
CometExecRDD.getPartitions.
-      case _: BroadcastQueryStageExec | _: CometBroadcastExchangeExec =>
-        (Map.empty, Map.empty)
-
-      // Stage boundaries - stop searching (partition indices won't align 
after these)
-      case _: ShuffleQueryStageExec | _: AQEShuffleReadExec | _: 
CometShuffleExchangeExec |
-          _: CometUnionExec | _: CometTakeOrderedAndProjectExec | _: 
CometCoalesceExec |
-          _: ReusedExchangeExec | _: CometSparkToColumnarExec =>
-        (Map.empty, Map.empty)
-
-      // Continue searching through other operators, combining results from 
all children
-      case _ =>
-        val results = plan.children.map(findAllPlanData)
-        (results.flatMap(_._1).toMap, results.flatMap(_._2).toMap)
-    }
-  }
-
   /**
    * Converts this native Comet operator and its children into a native block 
which can be
    * executed as a whole (i.e., in a single JNI call) from the native side.
@@ -901,6 +942,52 @@ abstract class CometLeafExec extends CometNativeExec with 
LeafExecNode {
   }
 }
 
+/**
+ * Marker trait for scan execs that surface planning data (a `commonData` 
block + per-partition
+ * task bytes keyed by `sourceKey`) so that a parent `CometNativeExec` can 
find and inject the
+ * data when the scan is fused into a larger native subtree.
+ *
+ * Implemented by `CometNativeScanExec` and the contrib's 
`CometDeltaNativeScanExec` -- without
+ * it, [[PlanDataInjector.findAllPlanData]] cannot collect the per-partition 
tasks and the
+ * parent's native execution receives an empty input. 
(`CometIcebergNativeScanExec` does NOT use
+ * this trait; it has a dedicated `findAllPlanData` case.)
+ *
+ * Each implementation also resolves its own DPP subqueries via 
`ensureSubqueriesResolved` before
+ * `commonData`/`perPartitionData` are read. That method lives on 
[[CometLeafExec]], so the `self:
+ * CometLeafExec` self-type below makes "is a leaf scan" a compile-time 
requirement: an
+ * implementer cannot forget to extend [[CometLeafExec]] (which would 
otherwise compile and then
+ * silently skip subquery resolution -- the deadlock surface 
`ensureSubqueriesResolved` exists to
+ * prevent). It also lets [[PlanDataInjector.findAllPlanData]] drive the 
lifecycle without a
+ * runtime "not a leaf" fallback.
+ */
+trait CometScanWithPlanData { self: CometLeafExec =>
+  def sourceKey: String
+  def commonData: Array[Byte]
+  def perPartitionData: Array[Array[Byte]]
+
+  // DPP / partition filters that may carry AQE SubqueryAdaptiveBroadcast
+  // subqueries needing rewrite by CometPlanAdaptiveDynamicPruningFilters.
+  // Default empty: scans with dedicated handling (CometNativeScanExec,
+  // CometIcebergNativeScanExec) don't use this path.
+  def dynamicPruningFilters: Seq[Expression] = Nil
+
+  // Install rewritten DPP filters on this scan. Implementers whose filters 
live
+  // in a @transient field (which TreeNode.makeCopy can't carry, #3510) update
+  // them via a transient side-channel and return `this` -- so the optimizer
+  // rule's rewrite lands on the SAME instance that executes, instead of a copy
+  // that gets dropped when the enclosing native block is rebuilt. Only called
+  // when `dynamicPruningFilters` is non-empty, so the default is never reached
+  // for scans that leave it empty.
+  //
+  // TODO(#3510): once TreeNode.makeCopy preserves @transient fields, the
+  // mutate-and-return-`this` workaround can collapse back to a normal copy, 
mirroring
+  // the matching TODO in CometPlanAdaptiveDynamicPruningFilters.
+  def withDynamicPruningFilters(filters: Seq[Expression]): SparkPlan =
+    throw new UnsupportedOperationException(
+      s"${getClass.getSimpleName} exposes dynamicPruningFilters but does not " 
+
+        "override withDynamicPruningFilters")
+}
+
 abstract class CometUnaryExec extends CometNativeExec with UnaryExecNode
 
 abstract class CometBinaryExec extends CometNativeExec with BinaryExecNode
diff --git 
a/spark/src/test/scala/org/apache/spark/sql/comet/CometScanWithPlanDataSuite.scala
 
b/spark/src/test/scala/org/apache/spark/sql/comet/CometScanWithPlanDataSuite.scala
new file mode 100644
index 0000000000..0a3fc25a60
--- /dev/null
+++ 
b/spark/src/test/scala/org/apache/spark/sql/comet/CometScanWithPlanDataSuite.scala
@@ -0,0 +1,187 @@
+/*
+ * 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.spark.sql.comet
+
+import java.io.File
+import java.net.URLClassLoader
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.util.ServiceLoader
+
+import scala.jdk.CollectionConverters._
+
+import org.scalatest.funsuite.AnyFunSuite
+
+import org.apache.spark.sql.catalyst.expressions.{Attribute, Expression}
+import org.apache.spark.sql.execution.SparkPlan
+
+import org.apache.comet.serde.OperatorOuterClass.Operator
+
+/**
+ * Unit coverage for the core SPI that lets out-of-tree contrib leaf scans 
(e.g. the Delta
+ * contrib's `CometDeltaNativeScanExec`) participate in Comet native planning 
without core holding
+ * a compile-time reference to them:
+ *
+ *   - the [[CometScanWithPlanData]] trait contract and its collection through
+ *     [[PlanDataInjector.findAllPlanData]], and
+ *   - the `ServiceLoader`-based discovery of contrib [[PlanDataInjector]]s in
+ *     [[PlanDataInjector]]'s registry.
+ *
+ * Deliberately does not exercise the per-op injector registry mechanics; that 
surface is owned by
+ * `PlanDataInjectorSuite`.
+ */
+class CometScanWithPlanDataSuite extends AnyFunSuite {
+
+  /**
+   * Minimal `CometLeafExec with CometScanWithPlanData` (the trait's `self: 
CometLeafExec`
+   * self-type forces the leaf base). It opts into none of the optional DPP 
behaviour and records
+   * whether the subquery-resolution lifecycle hook was driven, so a test can 
confirm
+   * `findAllPlanData` routes a non-`CometNativeScanExec` trait scan through 
it.
+   */
+  private case class StubScan(
+      override val sourceKey: String = "stub-key",
+      common: Array[Byte] = Array.emptyByteArray,
+      perPart: Array[Array[Byte]] = Array.empty)
+      extends CometLeafExec
+      with CometScanWithPlanData {
+
+    @transient var subqueriesResolved: Boolean = false
+
+    // Real CometLeafExec.ensureSubqueriesResolved drives Spark's 
prepare/waitForSubqueries, which
+    // needs a live session; for this pure unit test we record the call 
instead. The behaviour
+    // under test is that findAllPlanData *invokes* the hook for a trait scan, 
not the hook itself
+    // (CometLeafExec.ensureSubqueriesResolved is exercised by the operator 
suites end to end).
+    override def ensureSubqueriesResolved(): Unit = { subqueriesResolved = 
true }
+
+    // findAllPlanData only reads the trait members + the lifecycle hook; 
originalPlan (used by
+    // CometExec for output/ordering/partitioning) is never touched on this 
path.
+    override def output: Seq[Attribute] = Seq.empty
+    override def originalPlan: SparkPlan = null
+    override def nativeOp: Operator = Operator.getDefaultInstance
+    override def serializedPlanOpt: SerializedPlan = SerializedPlan(None)
+    override def commonData: Array[Byte] = common
+    override def perPartitionData: Array[Array[Byte]] = perPart
+  }
+
+  test(
+    "CometScanWithPlanData defaults: no DPP filters and 
withDynamicPruningFilters is unsupported") {
+    val scan = StubScan()
+    assert(scan.dynamicPruningFilters == Nil, "a scan must expose no DPP 
filters by default")
+    // The default exists only so the DPP rule never calls it for scans that 
leave
+    // dynamicPruningFilters empty; if a scan does expose filters it must 
override this.
+    val ex = intercept[UnsupportedOperationException] {
+      scan.withDynamicPruningFilters(Seq.empty[Expression])
+    }
+    assert(
+      ex.getMessage.contains("withDynamicPruningFilters"),
+      s"default failure must name the un-overridden method, got: 
${ex.getMessage}")
+  }
+
+  test("findAllPlanData collects data from a non-CometNativeScanExec trait 
scan") {
+    // The whole point of the SPI: a brand-new leaf scan that core has never 
heard of, surfacing
+    // its data only through CometScanWithPlanData, is collected by 
findAllPlanData. This exercises
+    // the generic trait arm independently of CometNativeScanExec / 
CometIcebergNativeScanExec.
+    val common = Array[Byte](1, 2, 3)
+    val perPart = Array(Array[Byte](10), Array[Byte](20))
+    val scan = StubScan("contrib-key", common, perPart)
+
+    val (commonByKey, perPartitionByKey) = 
PlanDataInjector.findAllPlanData(scan)
+
+    assert(commonByKey.keySet == Set("contrib-key"))
+    assert(commonByKey("contrib-key") sameElements common)
+    assert(perPartitionByKey.keySet == Set("contrib-key"))
+    assert(perPartitionByKey("contrib-key").map(_.toSeq).toSeq == 
perPart.map(_.toSeq).toSeq)
+    assert(
+      scan.subqueriesResolved,
+      "findAllPlanData must drive the scan's subquery-resolution lifecycle 
before reading data")
+  }
+
+  test("findAllPlanData skips a trait scan that surfaces no data") {
+    // Mirrors the Iceberg arm: an implementation that returns empty 
common/per-partition data
+    // contributes nothing, rather than an empty-array entry that would fail 
downstream injection.
+    val scan = StubScan("empty-key")
+    val (commonByKey, perPartitionByKey) = 
PlanDataInjector.findAllPlanData(scan)
+    assert(commonByKey.isEmpty)
+    assert(perPartitionByKey.isEmpty)
+  }
+
+  test("PlanDataInjector registry contains only built-in injectors on a 
default build") {
+    // No contrib ships a META-INF/services/...PlanDataInjector on the default 
test classpath, so
+    // ServiceLoader discovery adds nothing and the registry is exactly the 
two built-ins.
+    val injectors = PlanDataInjector.injectors
+    assert(
+      injectors.contains(IcebergPlanDataInjector),
+      "registry must keep the built-in Iceberg injector")
+    assert(
+      injectors.contains(NativeScanPlanDataInjector),
+      "registry must keep the built-in native-scan injector")
+    assert(
+      injectors.forall(i => i == IcebergPlanDataInjector || i == 
NativeScanPlanDataInjector),
+      s"default build must carry no contrib injectors, got: 
${injectors.map(_.getClass.getName)}")
+  }
+
+  test("a contrib PlanDataInjector is discovered via ServiceLoader 
(META-INF/services)") {
+    // Proves the discovery contract a contrib relies on: dropping a service 
file naming a
+    // PlanDataInjector implementation makes it visible to ServiceLoader -- no 
core change. We use
+    // an isolated child classloader carrying only the service file so the 
global registry (asserted
+    // built-ins-only above) is untouched.
+    // The scalatest plugin points java.io.tmpdir at spark/target/tmp, which 
may not exist yet;
+    // ensure it before createTempDirectory (which requires its parent to 
exist).
+    val baseTmp = new File(System.getProperty("java.io.tmpdir"))
+    baseTmp.mkdirs()
+    val svcDir = Files.createTempDirectory(baseTmp.toPath, 
"comet-spi-test").toFile
+    try {
+      val servicesDir = new File(svcDir, "META-INF/services")
+      assert(servicesDir.mkdirs(), s"could not create $servicesDir")
+      Files.write(
+        new File(servicesDir, classOf[PlanDataInjector].getName).toPath,
+        (classOf[TestStubPlanDataInjector].getName + 
"\n").getBytes(StandardCharsets.UTF_8))
+
+      val loader = new URLClassLoader(Array(svcDir.toURI.toURL), 
getClass.getClassLoader)
+      val discovered =
+        ServiceLoader.load(classOf[PlanDataInjector], loader).asScala.toSeq
+      assert(
+        discovered.exists(_.isInstanceOf[TestStubPlanDataInjector]),
+        s"ServiceLoader should discover the stub, got: 
${discovered.map(_.getClass.getName)}")
+    } finally {
+      def del(f: File): Unit = {
+        Option(f.listFiles()).foreach(_.foreach(del))
+        f.delete()
+      }
+      del(svcDir)
+    }
+  }
+}
+
+/**
+ * Top-level public no-arg `PlanDataInjector` so `ServiceLoader` can 
instantiate it (Scala
+ * `object`s have a private constructor and would not be instantiable). Inert: 
it claims no
+ * operator kind.
+ */
+class TestStubPlanDataInjector extends PlanDataInjector {
+  override def opStructCase: Operator.OpStructCase = 
Operator.OpStructCase.OPSTRUCT_NOT_SET
+  override def canInject(op: Operator): Boolean = false
+  override def getKey(op: Operator): Option[String] = None
+  override def inject(
+      op: Operator,
+      commonBytes: Array[Byte],
+      partitionBytes: Array[Byte]): Operator =
+    op
+}


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


Reply via email to