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

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


The following commit(s) were added to refs/heads/master by this push:
     new bc2f955bd11 Add RuleSetCustomizer SPI for multi-stage planner Calcite 
rules (#18387)
bc2f955bd11 is described below

commit bc2f955bd11224ebacc0aab03761ab4e3cc1ebbd
Author: Gonzalo Ortiz Jaureguizar <[email protected]>
AuthorDate: Fri May 22 13:40:49 2026 +0200

    Add RuleSetCustomizer SPI for multi-stage planner Calcite rules (#18387)
---
 .../pom.xml                                        |  28 ++---
 .../org/apache/pinot/query/planner/spi/Phase.java  |  59 ++++++++++
 .../pinot/query/planner/spi/RuleSetCustomizer.java |  89 +++++++++++++++
 .../pinot/spi/plugin/PluginRealmExportTest.java    |  78 +++++++++++++
 pinot-query-planner/pom.xml                        |   8 ++
 .../calcite/rel/rules/PinotQueryRuleSets.java      |  80 ++++++-------
 .../org/apache/pinot/query/QueryEnvironment.java   |  56 ++++++---
 .../planner/rules/DefaultRuleSetCustomizer.java    |  70 ++++++++++++
 .../pinot/query/planner/rules/PinotRuleSet.java    | 127 +++++++++++++++++++++
 .../query/planner/rules/PinotRuleSetTest.java      |  97 ++++++++++++++++
 .../org/apache/pinot/spi/plugin/PluginManager.java |  45 +++++++-
 .../apache/pinot/spi/plugin/PluginManagerTest.java |  41 +++++++
 pom.xml                                            |   6 +
 13 files changed, 700 insertions(+), 84 deletions(-)

diff --git a/pinot-query-planner/pom.xml b/pinot-query-planner-spi/pom.xml
similarity index 70%
copy from pinot-query-planner/pom.xml
copy to pinot-query-planner-spi/pom.xml
index 484fcd62524..3f88b02dfa5 100644
--- a/pinot-query-planner/pom.xml
+++ b/pinot-query-planner-spi/pom.xml
@@ -27,8 +27,8 @@
     <groupId>org.apache.pinot</groupId>
     <version>1.6.0-SNAPSHOT</version>
   </parent>
-  <artifactId>pinot-query-planner</artifactId>
-  <name>Pinot Query Planner</name>
+  <artifactId>pinot-query-planner-spi</artifactId>
+  <name>Pinot Query Planner SPI</name>
   <url>https://pinot.apache.org/</url>
 
   <properties>
@@ -37,37 +37,23 @@
 
   <dependencies>
     <dependency>
-      <groupId>org.apache.pinot</groupId>
-      <artifactId>pinot-core</artifactId>
-    </dependency>
-    <dependency>
-      <groupId>org.codehaus.janino</groupId>
-      <artifactId>janino</artifactId>
-    </dependency>
-    <dependency>
-      <groupId>org.codehaus.janino</groupId>
-      <artifactId>commons-compiler</artifactId>
+      <groupId>org.apache.calcite</groupId>
+      <artifactId>calcite-core</artifactId>
     </dependency>
     <dependency>
-      <groupId>org.immutables</groupId>
-      <artifactId>value-annotations</artifactId>
+      <groupId>com.google.code.findbugs</groupId>
+      <artifactId>jsr305</artifactId>
     </dependency>
 
     <dependency>
       <groupId>org.apache.pinot</groupId>
-      <artifactId>pinot-core</artifactId>
+      <artifactId>pinot-spi</artifactId>
       <scope>test</scope>
-      <type>test-jar</type>
     </dependency>
     <dependency>
       <groupId>org.testng</groupId>
       <artifactId>testng</artifactId>
       <scope>test</scope>
     </dependency>
-    <dependency>
-      <groupId>org.mockito</groupId>
-      <artifactId>mockito-core</artifactId>
-      <scope>test</scope>
-    </dependency>
   </dependencies>
 </project>
diff --git 
a/pinot-query-planner-spi/src/main/java/org/apache/pinot/query/planner/spi/Phase.java
 
b/pinot-query-planner-spi/src/main/java/org/apache/pinot/query/planner/spi/Phase.java
new file mode 100644
index 00000000000..22923951c61
--- /dev/null
+++ 
b/pinot-query-planner-spi/src/main/java/org/apache/pinot/query/planner/spi/Phase.java
@@ -0,0 +1,59 @@
+/**
+ * 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.pinot.query.planner.spi;
+
+
+/// HEP phases the multi-stage planner exposes to plugin
+/// [RuleSetCustomizer]s. Every phase corresponds to one slot in the rule
+/// programs built by `QueryEnvironment#getOptProgram` /
+/// `QueryEnvironment#getTraitProgram`.
+///
+/// **Stability**: this enum is append-only — new phases may be added at the
+/// end without breaking plugins compiled against an older version. Existing
+/// phases must never be reordered or removed.
+public enum Phase {
+  /// Basic logical-rewrite phase. HEP, depth-first.
+  /// OSS defaults: `DefaultRuleSetCustomizer.BASIC_RULES`.
+  BASIC,
+
+  /// Filter pushdown rules. The HEP program runs this phase twice (around the
+  /// project pushdown phase). OSS defaults:
+  /// `DefaultRuleSetCustomizer.FILTER_PUSHDOWN_RULES`.
+  FILTER_PUSHDOWN,
+
+  /// Project pushdown rules.
+  /// OSS defaults: `DefaultRuleSetCustomizer.PROJECT_PUSHDOWN_RULES`.
+  PROJECT_PUSHDOWN,
+
+  /// Top-down pruning rules.
+  /// OSS defaults: `DefaultRuleSetCustomizer.PRUNE_RULES`.
+  PRUNE,
+
+  /// Post-logical rules used when the physical optimizer is **not** enabled.
+  /// OSS defaults: `DefaultRuleSetCustomizer.POST_LOGICAL_RULES`. The list
+  /// includes `PinotSortExchangeCopyRule.SORT_EXCHANGE_COPY` configured with
+  /// the rule's hard-coded default `fetchLimitThreshold`. Per-query overrides
+  /// (and broker-config overrides) are applied by `QueryEnvironment` swapping
+  /// the rule on a per-query copy of this list.
+  POST_LOGICAL,
+
+  /// Post-logical rules used when the physical optimizer **is** enabled.
+  /// OSS defaults: `DefaultRuleSetCustomizer.POST_LOGICAL_PHYSICAL_RULES`.
+  POST_LOGICAL_PHYSICAL_OPT
+}
diff --git 
a/pinot-query-planner-spi/src/main/java/org/apache/pinot/query/planner/spi/RuleSetCustomizer.java
 
b/pinot-query-planner-spi/src/main/java/org/apache/pinot/query/planner/spi/RuleSetCustomizer.java
new file mode 100644
index 00000000000..a8ee936a1cb
--- /dev/null
+++ 
b/pinot-query-planner-spi/src/main/java/org/apache/pinot/query/planner/spi/RuleSetCustomizer.java
@@ -0,0 +1,89 @@
+/**
+ * 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.pinot.query.planner.spi;
+
+import java.util.List;
+import org.apache.calcite.plan.RelOptRule;
+
+
+/// Plugin SPI for customizing the multi-stage planner's Calcite rule sets.
+///
+/// Implementations are discovered via [java.util.ServiceLoader]. Declare your
+/// customizer in
+/// `META-INF/services/org.apache.pinot.query.planner.spi.RuleSetCustomizer`
+/// inside the plugin JAR. Implementations must have a public no-arg 
constructor.
+///
+/// 
[org.apache.pinot.query.planner.rules.PinotRuleSet#loadFromServiceLoader()] 
first discovers
+/// application-classpath customizers, then iterates every plugin classloader 
registered with
+/// [org.apache.pinot.spi.plugin.PluginManager]. Plugin JARs must be loaded
+/// before 
[org.apache.pinot.query.planner.rules.PinotRuleSet#defaultInstance()] is first 
called;
+/// the singleton is initialized once and not refreshed.
+///
+///
+/// [org.apache.pinot.query.planner.rules.PinotRuleSet] invokes every 
discovered customizer once
+/// per [Phase] at broker startup. The customizer receives the mutable 
per-phase rule list
+/// and can append, remove, replace, or reorder rules using standard `List`
+/// operations. After every customizer has run, `PinotRuleSet` defensively
+/// copies each list to an immutable form; mutations to the supplied list
+/// after `customize` returns have no effect.
+///
+/// ### Iteration order
+///
+/// Customizers run in `ServiceLoader` iteration order — typically classpath
+/// order, which is JVM-dependent. The OSS defaults are themselves contributed
+/// by [org.apache.pinot.query.planner.rules.DefaultRuleSetCustomizer]; plugin 
authors that depend
+/// on observing the OSS defaults should not assume a specific position. To 
guarantee ordering
+/// across customizers, drop and re-add rules from your own `customize`
+/// implementation.
+///
+/// ### Example
+///
+/// ```java
+/// public class MyPluginRules implements RuleSetCustomizer {
+///   @Override public void customize(Phase phase, List<RelOptRule> rules) {
+///     if (phase == Phase.BASIC) {
+///       rules.add(MyOptimizationRule.INSTANCE);
+///       rules.removeIf(r -> "BadOldRule".equals(r.toString()));
+///     }
+///   }
+/// }
+/// ```
+///
+/// And 
`META-INF/services/org.apache.pinot.query.planner.spi.RuleSetCustomizer`:
+/// ```
+/// com.example.MyPluginRules
+/// ```
+///
+/// ### Thread safety
+///
+/// Customizers are invoked single-threaded during
+/// [org.apache.pinot.query.planner.rules.PinotRuleSet] construction.
+/// Implementations need not be thread-safe and must not retain references to
+/// the supplied list — the list is defensively copied after every customizer
+/// has run. A customizer that throws aborts construction and propagates the
+/// exception out of 
[org.apache.pinot.query.planner.rules.PinotRuleSet#loadFromServiceLoader()].
+public interface RuleSetCustomizer {
+
+  /// Modify the broker's rule list for the given phase. The list is mutable
+  /// during this call only; it is defensively copied after every customizer
+  /// has run. Implementations may simply return without mutating when the
+  /// phase is not one they care about. Implementations must not insert `null`
+  /// rules — `PinotRuleSet` will reject the resulting list.
+  void customize(Phase phase, List<RelOptRule> rules);
+}
diff --git 
a/pinot-query-planner-spi/src/test/java/org/apache/pinot/spi/plugin/PluginRealmExportTest.java
 
b/pinot-query-planner-spi/src/test/java/org/apache/pinot/spi/plugin/PluginRealmExportTest.java
new file mode 100644
index 00000000000..5592437d16b
--- /dev/null
+++ 
b/pinot-query-planner-spi/src/test/java/org/apache/pinot/spi/plugin/PluginRealmExportTest.java
@@ -0,0 +1,78 @@
+/**
+ * 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.pinot.spi.plugin;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.nio.file.Files;
+import java.util.Set;
+import java.util.jar.JarOutputStream;
+import org.apache.pinot.query.planner.spi.RuleSetCustomizer;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+
+/// Verifies that {@link PluginManager} exports the SPI packages from the pinot
+/// realm into each plugin realm, so plugin JARs can implement
+/// {@link RuleSetCustomizer} without bundling or shading those classes.
+///
+/// This test lives in the {@code org.apache.pinot.spi.plugin} package (in a
+/// different Maven module) to access the package-private {@link PluginManager}
+/// constructor.
+public class PluginRealmExportTest {
+
+  @Test
+  public void pluginRealmCanLoadRuleSetCustomizer()
+      throws Exception {
+    File tempDir = Files.createTempDirectory("pinot-spi-export-test").toFile();
+    try {
+      // Minimal new-style plugin: just pinot-plugin.properties + an empty JAR.
+      Files.createFile(tempDir.toPath().resolve("pinot-plugin.properties"));
+      try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(new 
File(tempDir, "dummy.jar")))) {
+        // intentionally empty
+      }
+
+      PluginManager pm = new PluginManager();
+      pm.load("spi-export-test-plugin", tempDir);
+
+      Set<ClassLoader> loaders = pm.getPluginClassLoaders();
+      Assert.assertEquals(loaders.size(), 1, "Exactly one plugin realm should 
be registered");
+
+      ClassLoader pluginRealm = loaders.iterator().next();
+
+      // RuleSetCustomizer is in org.apache.pinot.query.planner.spi, which 
PluginManager
+      // exports from the pinotRealm. Loading it from the plugin realm must 
succeed.
+      Class<?> customizerClass = 
pluginRealm.loadClass(RuleSetCustomizer.class.getName());
+      Assert.assertNotNull(customizerClass);
+      Assert.assertTrue(customizerClass.isInterface(), "RuleSetCustomizer must 
be an interface");
+
+      // RelOptRule is in org.apache.calcite.plan, also exported by 
PluginManager.
+      Class<?> relOptRuleClass = 
pluginRealm.loadClass("org.apache.calcite.plan.RelOptRule");
+      Assert.assertNotNull(relOptRuleClass);
+    } finally {
+      File[] files = tempDir.listFiles();
+      if (files != null) {
+        for (File f : files) {
+          f.delete();
+        }
+      }
+      tempDir.delete();
+    }
+  }
+}
diff --git a/pinot-query-planner/pom.xml b/pinot-query-planner/pom.xml
index 484fcd62524..f55f78b7b4b 100644
--- a/pinot-query-planner/pom.xml
+++ b/pinot-query-planner/pom.xml
@@ -36,6 +36,10 @@
   </properties>
 
   <dependencies>
+    <dependency>
+      <groupId>org.apache.pinot</groupId>
+      <artifactId>pinot-query-planner-spi</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.apache.pinot</groupId>
       <artifactId>pinot-core</artifactId>
@@ -52,6 +56,10 @@
       <groupId>org.immutables</groupId>
       <artifactId>value-annotations</artifactId>
     </dependency>
+    <dependency>
+      <groupId>com.google.auto.service</groupId>
+      <artifactId>auto-service-annotations</artifactId>
+    </dependency>
 
     <dependency>
       <groupId>org.apache.pinot</groupId>
diff --git 
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotQueryRuleSets.java
 
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotQueryRuleSets.java
index 60923dce79c..19909597302 100644
--- 
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotQueryRuleSets.java
+++ 
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotQueryRuleSets.java
@@ -49,13 +49,14 @@ import 
org.apache.pinot.spi.utils.CommonConstants.Broker.PlannerRuleNames;
 
 
 /**
- * Default rule sets for Pinot query
+ * Default rule sets for Pinot query.
  * Defaultly disabled rules are defined in
  * {@link 
org.apache.pinot.spi.utils.CommonConstants.Broker#DEFAULT_DISABLED_RULES}
  *
- * TODO: This class started as a list of constant rule sets, but since then we 
have added dynamic rule generation
- *   to it as well. We should probably refactor the class to make it easier to 
understand, maintain and change the rules
- *   based on contextual information like query options.
+ * <p>TODO: Rule lists may be consolidated into
+ * {@link org.apache.pinot.query.planner.rules.DefaultRuleSetCustomizer} in a 
future refactor once
+ * the {@link org.apache.pinot.query.planner.spi.RuleSetCustomizer} SPI is the 
established
+ * extension point for broker rule customization.
  */
 public class PinotQueryRuleSets {
   private PinotQueryRuleSets() {
@@ -218,6 +219,36 @@ public class PinotQueryRuleSets {
           .withDescription(PlannerRuleNames.PRUNE_EMPTY_UNION).toRule()
   );
 
+  /// Pinot specific post-logical rules used when the physical optimizer is 
<b>not</b> enabled.
+  /// Includes {@link PinotSortExchangeCopyRule#SORT_EXCHANGE_COPY} with the 
default fetch-limit
+  /// threshold. Per-query overrides are applied by {@code 
QueryEnvironment.getTraitProgram},
+  /// which swaps the configured rule on a per-query copy of this list.
+  public static final List<RelOptRule> POST_LOGICAL_RULES = List.of(
+      // TODO: Merge the following 2 rules into a single rule
+      // add an extra exchange for sort
+      PinotSortExchangeNodeInsertRule.INSTANCE,
+      PinotSortExchangeCopyRule.SORT_EXCHANGE_COPY,
+
+      PinotSingleValueAggregateRemoveRule.INSTANCE,
+      PinotJoinExchangeNodeInsertRule.INSTANCE,
+      PinotAggregateExchangeNodeInsertRule.SortProjectAggregate.INSTANCE,
+      PinotAggregateExchangeNodeInsertRule.SortAggregate.INSTANCE,
+      PinotAggregateExchangeNodeInsertRule.WithoutSort.INSTANCE,
+      PinotWindowSplitRule.INSTANCE,
+      PinotWindowExchangeNodeInsertRule.INSTANCE,
+      PinotSetOpExchangeNodeInsertRule.INSTANCE,
+
+      // apply dynamic broadcast rule after exchange is inserted
+      PinotJoinToDynamicBroadcastRule.INSTANCE,
+
+      // remove exchanges when there's duplicates
+      PinotExchangeEliminationRule.INSTANCE,
+
+      // Evaluate the Literal filter nodes
+      CoreRules.FILTER_REDUCE_EXPRESSIONS,
+      PinotTableScanConverterRule.INSTANCE
+  );
+
   public static final List<RelOptRule> PINOT_POST_RULES_V2 = List.of(
       PinotTableScanConverterRule.INSTANCE,
       PinotLogicalAggregateRule.SortProjectAggregate.INSTANCE,
@@ -228,45 +259,4 @@ public class PinotQueryRuleSets {
       CoreRules.FILTER_REDUCE_EXPRESSIONS
   );
   //@formatter:on
-
-  /// Pinot specific rules that should be run AFTER all other rules
-  public static List<RelOptRule> getPinotPostRules(int sortExchangeCopyLimit) {
-
-    // copy exchanges down, this must be done after SortExchangeNodeInsertRule
-    PinotSortExchangeCopyRule sortExchangeCopyRule;
-    if (sortExchangeCopyLimit != 
PinotSortExchangeCopyRule.SORT_EXCHANGE_COPY.config.getFetchLimitThreshold()) {
-      sortExchangeCopyRule = 
ImmutablePinotSortExchangeCopyRule.Config.builder()
-          .from(PinotSortExchangeCopyRule.Config.DEFAULT)
-          .fetchLimitThreshold(sortExchangeCopyLimit)
-          .build()
-          .toRule();
-    } else {
-      sortExchangeCopyRule = PinotSortExchangeCopyRule.SORT_EXCHANGE_COPY;
-    }
-    return List.of(
-        // TODO: Merge the following 2 rules into a single rule
-        // add an extra exchange for sort
-        PinotSortExchangeNodeInsertRule.INSTANCE,
-        sortExchangeCopyRule,
-
-        PinotSingleValueAggregateRemoveRule.INSTANCE,
-        PinotJoinExchangeNodeInsertRule.INSTANCE,
-        PinotAggregateExchangeNodeInsertRule.SortProjectAggregate.INSTANCE,
-        PinotAggregateExchangeNodeInsertRule.SortAggregate.INSTANCE,
-        PinotAggregateExchangeNodeInsertRule.WithoutSort.INSTANCE,
-        PinotWindowSplitRule.INSTANCE,
-        PinotWindowExchangeNodeInsertRule.INSTANCE,
-        PinotSetOpExchangeNodeInsertRule.INSTANCE,
-
-        // apply dynamic broadcast rule after exchange is inserted/
-        PinotJoinToDynamicBroadcastRule.INSTANCE,
-
-        // remove exchanges when there's duplicates
-        PinotExchangeEliminationRule.INSTANCE,
-
-        // Evaluate the Literal filter nodes
-        CoreRules.FILTER_REDUCE_EXPRESSIONS,
-        PinotTableScanConverterRule.INSTANCE
-    );
-  }
 }
diff --git 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
index e55566d1066..95be17357ac 100644
--- 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
+++ 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
@@ -57,10 +57,10 @@ import org.apache.calcite.tools.Frameworks;
 import org.apache.calcite.tools.RelBuilder;
 import org.apache.commons.collections4.MapUtils;
 import org.apache.commons.lang3.tuple.Pair;
+import org.apache.pinot.calcite.rel.rules.ImmutablePinotSortExchangeCopyRule;
 import org.apache.pinot.calcite.rel.rules.PinotEnrichedJoinRule;
 import org.apache.pinot.calcite.rel.rules.PinotImplicitTableHintRule;
 import org.apache.pinot.calcite.rel.rules.PinotJoinToDynamicBroadcastRule;
-import org.apache.pinot.calcite.rel.rules.PinotQueryRuleSets;
 import org.apache.pinot.calcite.rel.rules.PinotRelDistributionTraitRule;
 import org.apache.pinot.calcite.rel.rules.PinotRuleUtils;
 import org.apache.pinot.calcite.rel.rules.PinotSortExchangeCopyRule;
@@ -89,6 +89,9 @@ import 
org.apache.pinot.query.planner.physical.v2.PRelNodeTreeValidator;
 import 
org.apache.pinot.query.planner.physical.v2.PlanFragmentAndMailboxAssignment;
 import org.apache.pinot.query.planner.physical.v2.RelToPRelConverter;
 import org.apache.pinot.query.planner.plannode.PlanNode;
+import org.apache.pinot.query.planner.rules.PinotRuleSet;
+import org.apache.pinot.query.planner.spi.Phase;
+import org.apache.pinot.query.planner.spi.RuleSetCustomizer;
 import org.apache.pinot.query.routing.WorkerManager;
 import org.apache.pinot.query.type.TypeFactory;
 import org.apache.pinot.query.validate.BytesCastVisitor;
@@ -168,7 +171,7 @@ public class QueryEnvironment {
         rootSchema, List.of(database), _typeFactory, CONNECTION_CONFIG, 
config.isCaseSensitive());
     _defaultDisabledPlannerRules = _envConfig.defaultDisabledPlannerRules();
     // default optProgram with no skip rule options and no use rule options
-    _optProgram = getOptProgram(Set.of(), Set.of(), 
_defaultDisabledPlannerRules);
+    _optProgram = getOptProgram(_envConfig.getRuleSet(), Set.of(), Set.of(), 
_defaultDisabledPlannerRules);
     _multiClusterRoutingContext = multiClusterRoutingContext;
   }
 
@@ -206,7 +209,7 @@ public class QueryEnvironment {
       Set<String> skipRuleSet = QueryOptionsUtils.getSkipPlannerRules(options);
       if (!skipRuleSet.isEmpty() || !useRuleSet.isEmpty()) {
         // dynamically create optProgram according to rule options
-        optProgram = getOptProgram(skipRuleSet, useRuleSet, 
_defaultDisabledPlannerRules);
+        optProgram = getOptProgram(_envConfig.getRuleSet(), skipRuleSet, 
useRuleSet, _defaultDisabledPlannerRules);
       }
     }
     int sortExchangeCopyLimit = 
QueryOptionsUtils.getSortExchangeCopyThreshold(options,
@@ -536,7 +539,7 @@ public class QueryEnvironment {
    * @param defaultDisabledRuleSet parsed default disabled rule set from 
broker config
    * @return HepProgram that performs logical transformations
    */
-  private static HepProgram getOptProgram(Set<String> skipRuleSet, Set<String> 
useRuleSet,
+  private static HepProgram getOptProgram(PinotRuleSet ruleSet, Set<String> 
skipRuleSet, Set<String> useRuleSet,
       Set<String> defaultDisabledRuleSet) {
     HepProgramBuilder hepProgramBuilder = new HepProgramBuilder();
     // Set the match order as DEPTH_FIRST. The default is arbitrary which 
works the same as DEPTH_FIRST, but it's
@@ -544,16 +547,17 @@ public class QueryEnvironment {
     hepProgramBuilder.addMatchOrder(HepMatchOrder.DEPTH_FIRST);
 
     // ----
-    // Rules are disabled if its corresponding value is set to false in 
ruleFlags
-    // construct filtered BASIC_RULES, FILTER_PUSHDOWN_RULES, 
PROJECT_PUSHDOWN_RULES, PRUNE_RULES
+    // Rules are disabled if its corresponding value is set to false in 
ruleFlags.
+    // Sources come from PinotRuleSet (after every RuleSetCustomizer ran); 
per-query
+    // skip/use options are then applied by filterRuleList on a fresh copy.
     List<RelOptRule> basicRules =
-        filterRuleList(PinotQueryRuleSets.BASIC_RULES, skipRuleSet, 
useRuleSet, defaultDisabledRuleSet);
+        filterRuleList(ruleSet.rulesFor(Phase.BASIC), skipRuleSet, useRuleSet, 
defaultDisabledRuleSet);
     List<RelOptRule> filterPushdownRules =
-        filterRuleList(PinotQueryRuleSets.FILTER_PUSHDOWN_RULES, skipRuleSet, 
useRuleSet, defaultDisabledRuleSet);
+        filterRuleList(ruleSet.rulesFor(Phase.FILTER_PUSHDOWN), skipRuleSet, 
useRuleSet, defaultDisabledRuleSet);
     List<RelOptRule> projectPushdownRules =
-        filterRuleList(PinotQueryRuleSets.PROJECT_PUSHDOWN_RULES, skipRuleSet, 
useRuleSet, defaultDisabledRuleSet);
+        filterRuleList(ruleSet.rulesFor(Phase.PROJECT_PUSHDOWN), skipRuleSet, 
useRuleSet, defaultDisabledRuleSet);
     List<RelOptRule> pruneRules =
-        filterRuleList(PinotQueryRuleSets.PRUNE_RULES, skipRuleSet, 
useRuleSet, defaultDisabledRuleSet);
+        filterRuleList(ruleSet.rulesFor(Phase.PRUNE), skipRuleSet, useRuleSet, 
defaultDisabledRuleSet);
 
     // Run the Calcite CORE rules using 1 HepInstruction per rule. We use 1 
HepInstruction per rule for simplicity:
     // the rules used here can rest assured that they are the only ones 
evaluated in a dedicated graph-traversal.
@@ -628,6 +632,7 @@ public class QueryEnvironment {
   private static HepProgram getTraitProgram(@Nullable WorkerManager 
workerManager, Config config,
       boolean usePhysicalOptimizer, Set<String> useRuleSet, int 
sortExchangeCopyLimit) {
     HepProgramBuilder hepProgramBuilder = new HepProgramBuilder();
+    PinotRuleSet ruleSet = config.getRuleSet();
 
     // Set the match order as BOTTOM_UP.
     hepProgramBuilder.addMatchOrder(HepMatchOrder.BOTTOM_UP);
@@ -635,25 +640,37 @@ public class QueryEnvironment {
     // ----
     // Run pinot specific rules that should run after all other rules, using 1 
HepInstruction per rule.
     if (!usePhysicalOptimizer) {
-      for (RelOptRule relOptRule : 
PinotQueryRuleSets.getPinotPostRules(sortExchangeCopyLimit)) {
+      // POST_LOGICAL list comes from PinotRuleSet; we copy it because we may 
need to
+      // swap every PinotSortExchangeCopyRule with one configured for the 
per-query
+      // (or broker-config) sortExchangeCopyLimit if it differs from the 
rule's default.
+      List<RelOptRule> postLogical = new 
ArrayList<>(ruleSet.rulesFor(Phase.POST_LOGICAL));
+      if (sortExchangeCopyLimit != 
PinotSortExchangeCopyRule.SORT_EXCHANGE_COPY.config.getFetchLimitThreshold()) {
+        PinotSortExchangeCopyRule overridden = 
ImmutablePinotSortExchangeCopyRule.Config.builder()
+            .from(PinotSortExchangeCopyRule.Config.DEFAULT)
+            .fetchLimitThreshold(sortExchangeCopyLimit)
+            .build()
+            .toRule();
+        postLogical.replaceAll(r -> r instanceof PinotSortExchangeCopyRule ? 
overridden : r);
+      }
+      for (RelOptRule relOptRule : postLogical) {
         if (isEligibleQueryPostRule(relOptRule, config)) {
           hepProgramBuilder.addRuleInstance(relOptRule);
         }
       }
       if 
(!isRuleSkipped(CommonConstants.Broker.PlannerRuleNames.JOIN_TO_ENRICHED_JOIN, 
Set.of(), useRuleSet,
           config.defaultDisabledPlannerRules())) {
-        // push filter and project above join to enrichedJoin, does not work 
with physical optimizer
         
hepProgramBuilder.addRuleCollection(PinotEnrichedJoinRule.PINOT_ENRICHED_JOIN_RULES);
       }
     } else {
-      for (RelOptRule relOptRule : PinotQueryRuleSets.PINOT_POST_RULES_V2) {
+      for (RelOptRule relOptRule : 
ruleSet.rulesFor(Phase.POST_LOGICAL_PHYSICAL_OPT)) {
         if (isEligibleQueryPostRule(relOptRule, config)) {
           hepProgramBuilder.addRuleInstance(relOptRule);
         }
       }
     }
     if (!usePhysicalOptimizer) {
-      // apply RelDistribution trait to all nodes
+      // apply RelDistribution trait to all nodes — these rules depend on the
+      // per-query WorkerManager, so they stay outside PinotRuleSet.
       if (workerManager != null) {
         
hepProgramBuilder.addRuleInstance(PinotImplicitTableHintRule.withWorkerManager(workerManager));
       }
@@ -695,6 +712,17 @@ public class QueryEnvironment {
     @Nullable
     TableCache getTableCache();
 
+    /**
+     * The multi-stage planner's per-phase Calcite rule lists. Defaults to the
+     * process-wide singleton built from {@link 
java.util.ServiceLoader}-discovered
+     * {@link RuleSetCustomizer}s, so per-query {@link Config} instances do not
+     * repeat discovery work.
+     */
+    @Value.Default
+    default PinotRuleSet getRuleSet() {
+      return PinotRuleSet.defaultInstance();
+    }
+
     @Value.Default
     default boolean isNullHandlingEnabled() {
       return false;
diff --git 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/rules/DefaultRuleSetCustomizer.java
 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/rules/DefaultRuleSetCustomizer.java
new file mode 100644
index 00000000000..323c8e3b1f5
--- /dev/null
+++ 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/rules/DefaultRuleSetCustomizer.java
@@ -0,0 +1,70 @@
+/**
+ * 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.pinot.query.planner.rules;
+
+import com.google.auto.service.AutoService;
+import java.util.List;
+import org.apache.calcite.plan.RelOptRule;
+import org.apache.pinot.calcite.rel.rules.PinotQueryRuleSets;
+import org.apache.pinot.query.planner.spi.Phase;
+import org.apache.pinot.query.planner.spi.RuleSetCustomizer;
+
+
+/// [RuleSetCustomizer] that seeds every [Phase] with the OSS default Calcite
+/// rules for the multi-stage query planner. Registered as a 
[java.util.ServiceLoader]
+/// service via [@AutoService], picked up automatically by [PinotRuleSet].
+///
+/// Rule lists are defined in [PinotQueryRuleSets] and may be consolidated here
+/// in a future refactor once the [RuleSetCustomizer] SPI is the established
+/// extension point for broker rule customization.
+@AutoService(RuleSetCustomizer.class)
+public final class DefaultRuleSetCustomizer implements RuleSetCustomizer {
+
+  /// No-arg constructor required by [java.util.ServiceLoader].
+  public DefaultRuleSetCustomizer() {
+  }
+
+  @Override
+  public void customize(Phase phase, List<RelOptRule> rules) {
+    switch (phase) {
+      case BASIC:
+        rules.addAll(PinotQueryRuleSets.BASIC_RULES);
+        return;
+      case FILTER_PUSHDOWN:
+        rules.addAll(PinotQueryRuleSets.FILTER_PUSHDOWN_RULES);
+        return;
+      case PROJECT_PUSHDOWN:
+        rules.addAll(PinotQueryRuleSets.PROJECT_PUSHDOWN_RULES);
+        return;
+      case PRUNE:
+        rules.addAll(PinotQueryRuleSets.PRUNE_RULES);
+        return;
+      case POST_LOGICAL:
+        rules.addAll(PinotQueryRuleSets.POST_LOGICAL_RULES);
+        return;
+      case POST_LOGICAL_PHYSICAL_OPT:
+        rules.addAll(PinotQueryRuleSets.PINOT_POST_RULES_V2);
+        return;
+      default:
+        throw new IllegalStateException(
+            "DefaultRuleSetCustomizer is missing OSS rule defaults for Phase." 
+ phase
+                + "; extend the switch when adding a new Phase value.");
+    }
+  }
+}
diff --git 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/rules/PinotRuleSet.java
 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/rules/PinotRuleSet.java
new file mode 100644
index 00000000000..12b152e1438
--- /dev/null
+++ 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/rules/PinotRuleSet.java
@@ -0,0 +1,127 @@
+/**
+ * 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.pinot.query.planner.rules;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.ServiceLoader;
+import java.util.Set;
+import org.apache.calcite.plan.RelOptRule;
+import org.apache.pinot.query.planner.spi.Phase;
+import org.apache.pinot.query.planner.spi.RuleSetCustomizer;
+import org.apache.pinot.spi.plugin.PluginManager;
+
+
+/// Owns the multi-stage planner's per-phase Calcite rule lists. Constructed
+/// once at broker startup; lists are immutable for the process lifetime.
+///
+/// Construction sequence:
+///
+/// 1. Allocate an empty mutable list per [Phase].
+/// 2. For every [RuleSetCustomizer] (in supplied order), call
+///    `customize(phase, list)` once per phase. The OSS defaults are themselves
+///    contributed by [DefaultRuleSetCustomizer] which is registered as a
+///    `ServiceLoader` entry; plugin customizers run after and observe a list
+///    pre-populated with the OSS defaults.
+/// 3. Defensively copy each per-phase list to an immutable [List] and freeze
+///    the map.
+///
+/// `QueryEnvironment` reads `rulesFor(phase)` and applies per-query
+/// `usePlannerRules` / `skipPlannerRules` filters on top.
+public final class PinotRuleSet {
+
+  private final Map<Phase, List<RelOptRule>> _rulesByPhase;
+
+  /// Builds a rule set from the supplied customizers. Customizers run in the
+  /// order of the iterable; the framework freezes the per-phase lists after
+  /// every customizer has run.
+  public PinotRuleSet(Iterable<RuleSetCustomizer> customizers) {
+    EnumMap<Phase, List<RelOptRule>> mutable = new EnumMap<>(Phase.class);
+    for (Phase phase : Phase.values()) {
+      mutable.put(phase, new ArrayList<>());
+    }
+    for (RuleSetCustomizer customizer : customizers) {
+      for (Phase phase : Phase.values()) {
+        customizer.customize(phase, mutable.get(phase));
+      }
+    }
+    EnumMap<Phase, List<RelOptRule>> frozen = new EnumMap<>(Phase.class);
+    for (Map.Entry<Phase, List<RelOptRule>> entry : mutable.entrySet()) {
+      frozen.put(entry.getKey(), List.copyOf(entry.getValue()));
+    }
+    _rulesByPhase = Collections.unmodifiableMap(frozen);
+  }
+
+  /// Discovers every [RuleSetCustomizer] via [ServiceLoader] and builds a
+  /// rule set from them. Used by [#defaultInstance()] and by callers that
+  /// don't have an externally-managed customizer list.
+  ///
+  /// Discovery order:
+  /// 1. Application-classpath customizers (context classloader) — picks up
+  ///    [DefaultRuleSetCustomizer] and any customizer bundled with the broker.
+  /// 2. Plugin classloaders enumerated by 
[PluginManager#getPluginClassLoaders()] —
+  ///    picks up customizers registered in plugin JARs via
+  ///    
`META-INF/services/org.apache.pinot.query.planner.spi.RuleSetCustomizer`.
+  ///
+  /// Call after all plugins have been loaded via [PluginManager]; plugins 
loaded
+  /// after this method returns will not be included.
+  public static PinotRuleSet loadFromServiceLoader() {
+    List<RuleSetCustomizer> customizers = new ArrayList<>();
+    // Dedup by class name: the context classloader and a plugin classloader 
may both see the same
+    // META-INF/services file if their classpaths overlap (e.g. fat-jar + 
plugin realm).
+    Set<String> seen = new LinkedHashSet<>();
+    for (RuleSetCustomizer customizer : 
ServiceLoader.load(RuleSetCustomizer.class)) {
+      if (seen.add(customizer.getClass().getName())) {
+        customizers.add(customizer);
+      }
+    }
+    for (ClassLoader pluginClassLoader : 
PluginManager.get().getPluginClassLoaders()) {
+      for (RuleSetCustomizer customizer : 
ServiceLoader.load(RuleSetCustomizer.class, pluginClassLoader)) {
+        if (seen.add(customizer.getClass().getName())) {
+          customizers.add(customizer);
+        }
+      }
+    }
+    return new PinotRuleSet(customizers);
+  }
+
+  /// Lazily-initialized process-wide singleton built from the
+  /// `ServiceLoader`-discovered customizers. Used as the `@Value.Default` of
+  /// `QueryEnvironment.Config#getRuleSet()` so per-query `Config` instances
+  /// don't repeat the discovery work.
+  public static PinotRuleSet defaultInstance() {
+    return DefaultInstanceHolder.INSTANCE;
+  }
+
+  /// Returns the rule list for the given phase, after every customization
+  /// was applied. Per-query filtering by `usePlannerRules` /
+  /// `skipPlannerRules` is the caller's responsibility (see
+  /// `QueryEnvironment#getOptProgram`).
+  public List<RelOptRule> rulesFor(Phase phase) {
+    return _rulesByPhase.getOrDefault(phase, List.of());
+  }
+
+  private static final class DefaultInstanceHolder {
+    static final PinotRuleSet INSTANCE = loadFromServiceLoader();
+  }
+}
diff --git 
a/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/rules/PinotRuleSetTest.java
 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/rules/PinotRuleSetTest.java
new file mode 100644
index 00000000000..3f7ce49acb9
--- /dev/null
+++ 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/rules/PinotRuleSetTest.java
@@ -0,0 +1,97 @@
+/**
+ * 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.pinot.query.planner.rules;
+
+import java.util.List;
+import org.apache.calcite.plan.RelOptRule;
+import org.apache.pinot.calcite.rel.rules.PinotQueryRuleSets;
+import org.apache.pinot.query.planner.spi.Phase;
+import org.apache.pinot.query.planner.spi.RuleSetCustomizer;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertSame;
+import static org.testng.Assert.assertTrue;
+
+
+/// Unit tests for [PinotRuleSet] and the [RuleSetCustomizer] SPI.
+public class PinotRuleSetTest {
+
+  @Test
+  public void defaultsSeedEveryPhaseFromDefaultRuleSetCustomizer() {
+    PinotRuleSet ruleSet = new PinotRuleSet(List.of(new 
DefaultRuleSetCustomizer()));
+
+    assertEquals(ruleSet.rulesFor(Phase.BASIC), 
PinotQueryRuleSets.BASIC_RULES);
+    assertEquals(ruleSet.rulesFor(Phase.FILTER_PUSHDOWN), 
PinotQueryRuleSets.FILTER_PUSHDOWN_RULES);
+    assertEquals(ruleSet.rulesFor(Phase.PROJECT_PUSHDOWN), 
PinotQueryRuleSets.PROJECT_PUSHDOWN_RULES);
+    assertEquals(ruleSet.rulesFor(Phase.PRUNE), 
PinotQueryRuleSets.PRUNE_RULES);
+    assertEquals(ruleSet.rulesFor(Phase.POST_LOGICAL), 
PinotQueryRuleSets.POST_LOGICAL_RULES);
+    assertEquals(ruleSet.rulesFor(Phase.POST_LOGICAL_PHYSICAL_OPT), 
PinotQueryRuleSets.PINOT_POST_RULES_V2);
+  }
+
+  @Test
+  public void serviceLoaderDiscoveryFindsDefault() {
+    // The DefaultRuleSetCustomizer is registered via META-INF/services, so the
+    // default instance built by ServiceLoader exposes the OSS defaults.
+    PinotRuleSet ruleSet = PinotRuleSet.defaultInstance();
+    assertTrue(ruleSet.rulesFor(Phase.BASIC).size() > 0);
+    assertTrue(ruleSet.rulesFor(Phase.POST_LOGICAL).size() > 0);
+  }
+
+  @Test
+  public void customizerCanAppendRule() {
+    RelOptRule extraRule = PinotQueryRuleSets.BASIC_RULES.get(0);
+    int defaultSize = PinotQueryRuleSets.BASIC_RULES.size();
+
+    RuleSetCustomizer plugin = (phase, rules) -> {
+      if (phase == Phase.BASIC) {
+        rules.add(extraRule);
+      }
+    };
+    PinotRuleSet ruleSet = new PinotRuleSet(List.of(new 
DefaultRuleSetCustomizer(), plugin));
+
+    assertEquals(ruleSet.rulesFor(Phase.BASIC).size(), defaultSize + 1);
+    assertSame(ruleSet.rulesFor(Phase.BASIC).get(defaultSize), extraRule);
+  }
+
+  @Test
+  public void customizerCanRemoveOssRuleByName() {
+    String firstRuleName = PinotQueryRuleSets.BASIC_RULES.get(0).toString();
+    int defaultSize = PinotQueryRuleSets.BASIC_RULES.size();
+
+    RuleSetCustomizer plugin = (phase, rules) -> {
+      if (phase == Phase.BASIC) {
+        rules.removeIf(r -> firstRuleName.equals(r.toString()));
+      }
+    };
+    PinotRuleSet ruleSet = new PinotRuleSet(List.of(new 
DefaultRuleSetCustomizer(), plugin));
+
+    assertEquals(ruleSet.rulesFor(Phase.BASIC).size(), defaultSize - 1);
+    assertTrue(ruleSet.rulesFor(Phase.BASIC).stream().noneMatch(r -> 
firstRuleName.equals(r.toString())));
+  }
+
+  @Test
+  public void rulesForUnseededPhaseReturnsEmpty() {
+    // No customizers — every phase comes back empty.
+    PinotRuleSet ruleSet = new PinotRuleSet(List.of());
+    for (Phase phase : Phase.values()) {
+      assertEquals(ruleSet.rulesFor(phase).size(), 0, phase + " should be 
empty");
+    }
+  }
+}
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/plugin/PluginManager.java 
b/pinot-spi/src/main/java/org/apache/pinot/spi/plugin/PluginManager.java
index a8ae0bdc70b..ec39f672dc4 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/plugin/PluginManager.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/plugin/PluginManager.java
@@ -34,9 +34,11 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Properties;
+import java.util.Set;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.apache.commons.io.FileUtils;
@@ -298,10 +300,19 @@ public class PluginManager {
 
         ClassRealm pinotRealm = _classWorld.getClassRealm(PINOT_REALMID);
 
-        // All packages to look up in pinot realm BEFORE itself
-        Stream<String> importedPinotPackages =
-            Stream.of("org.apache.pinot.spi"); // this works like a prefix, so 
ALL spi classes will be accessible
-        importedPinotPackages.forEach(p -> pluginRealm.importFrom(pinotRealm, 
p));
+        // All packages to look up in pinot realm BEFORE itself.
+        // Acts like a prefix so all classes in (and below) the listed package 
are accessible.
+        // Hardcoding the list here is cheap while it stays small. A cleaner 
long-term approach
+        // would have each SPI module self-declare its exports in a 
META-INF/pinot-realm-exports
+        // resource file (one package per line) and have PluginManager 
discover them at init time
+        // via ClassLoader.getResources() — that would eliminate the layering 
violation of
+        // pinot-spi naming packages from higher-level modules such as 
pinot-query-planner-spi.
+        // TODO: implement the self-declaring META-INF/pinot-realm-exports 
mechanism.
+        Stream.of(
+            "org.apache.pinot.spi",
+            "org.apache.pinot.query.planner.spi",     // RuleSetCustomizer SPI 
(pinot-query-planner-spi)
+            "org.apache.calcite.plan"                 // RelOptRule, used by 
RuleSetCustomizer.customize
+        ).forEach(p -> pluginRealm.importFrom(pinotRealm, p));
 
         // Additional importForm as specified by the plugin configuration
         config.getImportsFromPerRealm().forEach((r, ifs) -> {
@@ -474,6 +485,32 @@ public class PluginManager {
     return null;
   }
 
+  /// Returns the set of classloaders for all new-style plugins, in load order.
+  /// New-style plugins are those packaged with a `pinot-plugin.properties` 
file
+  /// and loaded into a dedicated [ClassRealm]. Legacy shaded plugins (loaded 
via
+  /// [PluginClassLoader]) are excluded — new SPIs should only target 
new-style plugins.
+  ///
+  /// Intended for `ServiceLoader` enumeration across plugin classloaders:
+  ///
+  /// ```java
+  /// for (ClassLoader cl : PluginManager.get().getPluginClassLoaders()) {
+  ///   for (MyService svc : ServiceLoader.load(MyService.class, cl)) { ... }
+  /// }
+  /// ```
+  ///
+  /// Call after all plugins have been loaded; classloaders added after this
+  /// call returns will not appear in the snapshot.
+  public synchronized Set<ClassLoader> getPluginClassLoaders() {
+    Set<ClassLoader> result = new LinkedHashSet<>();
+    for (ClassRealm realm : _classWorld.getRealms()) {
+      String id = realm.getId();
+      if (!PINOT_REALMID.equals(id) && !DEFAULT_PLUGIN_NAME.equals(id)) {
+        result.add(realm);
+      }
+    }
+    return Collections.unmodifiableSet(result);
+  }
+
   public static PluginManager get() {
     return PLUGIN_MANAGER;
   }
diff --git 
a/pinot-spi/src/test/java/org/apache/pinot/spi/plugin/PluginManagerTest.java 
b/pinot-spi/src/test/java/org/apache/pinot/spi/plugin/PluginManagerTest.java
index b5f8ba2d8e6..89918acc209 100644
--- a/pinot-spi/src/test/java/org/apache/pinot/spi/plugin/PluginManagerTest.java
+++ b/pinot-spi/src/test/java/org/apache/pinot/spi/plugin/PluginManagerTest.java
@@ -22,8 +22,10 @@ import java.io.File;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.net.URL;
+import java.nio.file.Files;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Set;
 import java.util.jar.JarEntry;
 import java.util.jar.JarOutputStream;
 import javax.tools.JavaCompiler;
@@ -339,6 +341,45 @@ public class PluginManagerTest {
     Assert.assertSame(instance1, instance2, "PluginManager should be a 
singleton");
   }
 
+  @Test
+  public void testGetPluginClassLoadersEmptyOnFreshInstance() {
+    PluginManager pm = new PluginManager();
+    Set<ClassLoader> loaders = pm.getPluginClassLoaders();
+    Assert.assertNotNull(loaders, "getPluginClassLoaders() must never return 
null");
+    Assert.assertTrue(loaders.isEmpty(),
+        "A fresh PluginManager with no loaded plugins should return an empty 
set");
+  }
+
+  @Test
+  public void testGetPluginClassLoadersIncludesNewStylePlugin()
+      throws Exception {
+    // New-style plugins (with pinot-plugin.properties) are loaded into a 
ClassRealm.
+    // Old-style plugins (without the file) are NOT returned by 
getPluginClassLoaders().
+    File newStyleDir = new File(_tempDir, "new-style-plugin-for-cl-test");
+    newStyleDir.mkdirs();
+    Files.createFile(newStyleDir.toPath().resolve("pinot-plugin.properties"));
+    try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(new 
File(newStyleDir, "dummy.jar")))) {
+      // intentionally empty JAR
+    }
+    File oldStyleDir = new File(_tempDir, "old-style-plugin-for-cl-test");
+    oldStyleDir.mkdirs();
+    try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(new 
File(oldStyleDir, "dummy.jar")))) {
+      // intentionally empty JAR — no pinot-plugin.properties
+    }
+
+    PluginManager pm = new PluginManager();
+    Assert.assertTrue(pm.getPluginClassLoaders().isEmpty(), "Should start 
empty");
+
+    pm.load("old-style-plugin-for-cl-test", oldStyleDir);
+    Assert.assertTrue(pm.getPluginClassLoaders().isEmpty(),
+        "Old-style plugins must not appear in getPluginClassLoaders()");
+
+    pm.load("new-style-plugin-for-cl-test", newStyleDir);
+    Set<ClassLoader> loaders = pm.getPluginClassLoaders();
+    Assert.assertEquals(loaders.size(), 1,
+        "New-style plugin must appear in getPluginClassLoaders()");
+  }
+
   @Test
   public void testRegisterRecordReaderClassCaseInsensitivity() {
     // Test that registration works with different case formats
diff --git a/pom.xml b/pom.xml
index b906345eb77..6d143121469 100644
--- a/pom.xml
+++ b/pom.xml
@@ -58,6 +58,7 @@
     <module>pinot-connectors</module>
     <module>pinot-segment-local</module>
     <module>pinot-compatibility-verifier</module>
+    <module>pinot-query-planner-spi</module>
     <module>pinot-query-planner</module>
     <module>pinot-query-runtime</module>
     <module>pinot-timeseries</module>
@@ -643,6 +644,11 @@
         <artifactId>pinot-materialized-view</artifactId>
         <version>${project.version}</version>
       </dependency>
+      <dependency>
+        <groupId>org.apache.pinot</groupId>
+        <artifactId>pinot-query-planner-spi</artifactId>
+        <version>${project.version}</version>
+      </dependency>
       <dependency>
         <groupId>org.apache.pinot</groupId>
         <artifactId>pinot-query-planner</artifactId>


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

Reply via email to