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

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


The following commit(s) were added to refs/heads/master by this push:
     new a8575529831 feat: improved partial load rule behavior (#19903)
a8575529831 is described below

commit a85755298317be02b29528b8f5d7a5f7550cb5af
Author: Clint Wylie <[email protected]>
AuthorDate: Mon Aug 10 12:03:33 2026 -0700

    feat: improved partial load rule behavior (#19903)
---
 .../segment/loading/PartialBaseTableLoadSpec.java  | 198 ++++++++++
 .../loading/PartialFullSegmentLoadSpec.java        | 131 +++++++
 .../druid/segment/loading/PartialLoadSpec.java     |  15 +
 .../loading/PartialBaseTableLoadSpecTest.java      | 432 +++++++++++++++++++++
 .../loading/PartialFullSegmentLoadSpecTest.java    | 277 +++++++++++++
 .../apache/druid/guice/PartialLoadSpecModule.java  |  10 +-
 .../coordinator/rules/CannotMatchBehavior.java     |  29 +-
 .../server/coordinator/rules/PartialLoadRule.java  |  62 ++-
 .../rules/ProjectionPartialLoadMatcher.java        |  29 +-
 ...egmentLocalCacheManagerPartialRuleLoadTest.java | 108 ++++++
 .../StrategicSegmentAssignerPartialTest.java       |  12 +-
 .../ExactProjectionPartialLoadMatcherTest.java     |  37 +-
 .../coordinator/rules/PartialLoadRuleTest.java     | 175 +++++++--
 .../WildcardProjectionPartialLoadMatcherTest.java  |  31 +-
 .../druid/server/http/DataSourcesResourceTest.java |  10 +-
 15 files changed, 1479 insertions(+), 77 deletions(-)

diff --git 
a/processing/src/main/java/org/apache/druid/segment/loading/PartialBaseTableLoadSpec.java
 
b/processing/src/main/java/org/apache/druid/segment/loading/PartialBaseTableLoadSpec.java
new file mode 100644
index 00000000000..dbd34cdca24
--- /dev/null
+++ 
b/processing/src/main/java/org/apache/druid/segment/loading/PartialBaseTableLoadSpec.java
@@ -0,0 +1,198 @@
+/*
+ * 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.druid.segment.loading;
+
+import com.fasterxml.jackson.annotation.JacksonInject;
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.segment.file.SegmentFileBuilder;
+import org.apache.druid.segment.file.SegmentFileMetadata;
+import 
org.apache.druid.segment.projections.ClusteredValueGroupsBaseTableSchema;
+import org.apache.druid.segment.projections.ProjectionMetadata;
+import org.apache.druid.segment.projections.Projections;
+import org.apache.druid.segment.projections.TableClusterGroupSpec;
+import org.apache.druid.timeline.DataSegment;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * A {@link PartialLoadSpec} that requests the segment's base table and 
nothing else; every row, no projections.
+ * <p>
+ * This is the floor of a partial load: it is what a matcher asks for when it 
has no scheme-specific content to
+ * contribute but the segment's rows must still be resident. A projection 
matcher whose configured projections aren't
+ * present on a segment resolves to this rather than going opaque, because a 
projection is always recomputable from
+ * the base table, so the base table is a correct (if slower) substitute for a 
missing one.
+ * <p>
+ * This load spec no scheme-specific field; "the base table" is not a name the 
coordinator can resolve. Where the rows
+ * actually live depends on the segment's physical layout, which only {@link 
#getSelectedBundleNames} can see, so the
+ * spec is just the base contract and resolution happens on the historical.
+ */
+@JsonTypeName(PartialBaseTableLoadSpec.TYPE)
+public class PartialBaseTableLoadSpec extends PartialLoadSpec
+{
+  public static final String TYPE = "partialBaseTable";
+
+  /**
+   * The fingerprint of every base-table load. Fixed, because the selection 
carries no scheme-specific content to
+   * distinguish: it is derived entirely from the segment's layout, so any two 
base-table loads of the same segment
+   * resolve identically.
+   */
+  public static final String FINGERPRINT = "v1:partial-base";
+
+  /**
+   * Builds the raw {@link Map} form of a {@link PartialBaseTableLoadSpec} 
request. Used by the coordinator-side
+   * matchers, which don't instantiate the typed class because doing so would 
require plumbing an
+   * {@link ObjectMapper} through every matcher just to satisfy the 
constructor's lazy-delegate supplier.
+   */
+  public static Map<String, Object> wireForm(Map<String, Object> delegate, 
String fingerprint)
+  {
+    return Map.of(
+        TYPE_FIELD, TYPE,
+        DELEGATE_FIELD, delegate,
+        FINGERPRINT_FIELD, fingerprint
+    );
+  }
+
+  @JsonCreator
+  public PartialBaseTableLoadSpec(
+      @JsonProperty("delegate") Map<String, Object> delegate,
+      @JsonProperty("fingerprint") String fingerprint,
+      @JacksonInject ObjectMapper jsonMapper
+  )
+  {
+    super(delegate, fingerprint, jsonMapper);
+  }
+
+  /**
+   * Resolves "the base table" to the bundles that physically hold the 
segment's rows. Which bundles those are is a
+   * property of the layout, not of the request:
+   * <ul>
+   *   <li><b>Clustered</b> — the rows are partitioned across the cluster 
groups, and {@code __base} holds only the
+   *       parts they share, so the base table is <em>every</em> {@code 
__base$<clusteringValueIds>} bundle, plus
+   *       {@code __base} itself when the segment carries one.</li>
+   *   <li><b>Unclustered</b> — the base table is the single {@code __base} 
bundle.</li>
+   *   <li><b>Legacy</b> — a V10 segment written before the bundle name was 
persisted reports every container under
+   *       {@link SegmentFileBuilder#ROOT_BUNDLE_NAME}, which is then the 
whole segment. Gated on root being the sole
+   *       bundle, matching {@code 
PartialSegmentBundleCacheEntry#resolveBundleName}.</li>
+   * </ul>
+   * Every returned name is checked against the bundles the segment actually 
carries first. That matters because
+   * {@code resolveBundleName} deliberately passes an unknown name through 
unchanged so the acquire fails loudly, so
+   * naming a bundle that isn't there would turn a layout the reader simply 
doesn't recognize into a hard load failure.
+   */
+  @Override
+  public List<String> getSelectedBundleNames(DataSegment segment, 
SegmentFileMetadata metadata)
+  {
+    final Set<String> present = presentBundleNames(metadata);
+    if (present.size() == 1 && 
present.contains(SegmentFileBuilder.ROOT_BUNDLE_NAME)) {
+      return List.of(SegmentFileBuilder.ROOT_BUNDLE_NAME);
+    }
+
+    final List<String> clusterGroupBundles = clusterGroupBundleNames(metadata);
+    if (clusterGroupBundles == null) {
+      if (!present.contains(Projections.BASE_TABLE_PROJECTION_NAME)) {
+        throw DruidException.defensive(
+            "Cannot resolve base-table bundles for segment[%s]: no [%s] bundle 
among %s",
+            segment.getId(),
+            Projections.BASE_TABLE_PROJECTION_NAME,
+            present
+        );
+      }
+      return List.of(Projections.BASE_TABLE_PROJECTION_NAME);
+    }
+
+    final List<String> selected = new ArrayList<>(clusterGroupBundles.size() + 
1);
+    // The shared base bundle is optional on a clustered segment: it exists 
only once the segment carries shared
+    // column parts. It is also an inferred dependency of every group bundle, 
so naming it here is belt and braces.
+    if (present.contains(Projections.BASE_TABLE_PROJECTION_NAME)) {
+      selected.add(Projections.BASE_TABLE_PROJECTION_NAME);
+    }
+    for (String groupBundle : clusterGroupBundles) {
+      if (!present.contains(groupBundle)) {
+        throw DruidException.defensive(
+            "Cannot resolve base-table bundles for segment[%s]: metadata 
declares cluster-group bundle[%s] but the"
+            + " segment carries %s",
+            segment.getId(),
+            groupBundle,
+            present
+        );
+      }
+      selected.add(groupBundle);
+    }
+    return selected;
+  }
+
+  /**
+   * Every cluster group's bundle name when the segment's base projection is 
clustered, or {@code null} when it isn't.
+   * Unlike {@link PartialClusterGroupLoadSpec}, this reads the group list 
straight out of the metadata rather than
+   * indexing into it, so it needs no cross-check against the segment's tuple 
count.
+   */
+  private static List<String> clusterGroupBundleNames(SegmentFileMetadata 
metadata)
+  {
+    final List<ProjectionMetadata> projections = metadata.getProjections();
+    if (projections == null || projections.isEmpty()) {
+      return null;
+    }
+    if (!(projections.getFirst().getSchema() instanceof 
ClusteredValueGroupsBaseTableSchema clusteredSummary)) {
+      return null;
+    }
+    final List<TableClusterGroupSpec> groups = 
clusteredSummary.getClusterGroups();
+    final List<String> bundleNames = new ArrayList<>(groups.size());
+    for (TableClusterGroupSpec group : groups) {
+      
bundleNames.add(Projections.getClusterGroupBundleName(group.getClusteringValueIds()));
+    }
+    return bundleNames;
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    PartialBaseTableLoadSpec that = (PartialBaseTableLoadSpec) o;
+    return Objects.equals(getDelegate(), that.getDelegate())
+        && Objects.equals(getFingerprint(), that.getFingerprint());
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Objects.hash(getDelegate(), getFingerprint());
+  }
+
+  @Override
+  public String toString()
+  {
+    return "PartialBaseTableLoadSpec{" +
+           "delegate=" + getDelegate() +
+           ", fingerprint=" + getFingerprint() +
+           '}';
+  }
+}
diff --git 
a/processing/src/main/java/org/apache/druid/segment/loading/PartialFullSegmentLoadSpec.java
 
b/processing/src/main/java/org/apache/druid/segment/loading/PartialFullSegmentLoadSpec.java
new file mode 100644
index 00000000000..deb2cd667ff
--- /dev/null
+++ 
b/processing/src/main/java/org/apache/druid/segment/loading/PartialFullSegmentLoadSpec.java
@@ -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.druid.segment.loading;
+
+import com.fasterxml.jackson.annotation.JacksonInject;
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.segment.file.SegmentFileMetadata;
+import org.apache.druid.timeline.DataSegment;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * A {@link PartialLoadSpec} that requests every bundle the segment contains. 
The ceiling of a partial load,
+ * as {@link PartialBaseTableLoadSpec} is its floor.
+ * <p>
+ * This exists so a rule can ask for the whole segment to be 
<em>resident</em>. Dispatching a segment without any
+ * partial-load wrapper does not do that on a virtual-storage historical: the 
segment is registered and its bundles
+ * are fetched on demand as queries touch them, which is a different thing 
from having them all on disk. Selecting
+ * every bundle through the rule path is what actually pins the whole segment, 
and it announces a fingerprint like any
+ * other partial load, so the coordinator can reconcile it.
+ * <p>
+ * Like {@link PartialBaseTableLoadSpec}, this carries no scheme-specific 
field — the selection is a property of the
+ * segment's layout, so it is resolved on the historical by {@link 
#getSelectedBundleNames}.
+ */
+@JsonTypeName(PartialFullSegmentLoadSpec.TYPE)
+public class PartialFullSegmentLoadSpec extends PartialLoadSpec
+{
+  public static final String TYPE = "partialFullSegment";
+
+  /**
+   * The fingerprint of every full-segment load. Fixed, for the same reason as
+   * {@link PartialBaseTableLoadSpec#FINGERPRINT}: the selection is derived 
entirely from the segment's layout, so any
+   * two full-segment loads of the same segment resolve identically.
+   */
+  public static final String FINGERPRINT = "v1:partial-full";
+
+  /**
+   * Builds the raw {@link Map} form of a {@link PartialFullSegmentLoadSpec} 
request. Used by the coordinator side,
+   * which doesn't instantiate the typed class because doing so would require 
plumbing an {@link ObjectMapper} through
+   * just to satisfy the constructor's lazy-delegate supplier.
+   */
+  public static Map<String, Object> wireForm(Map<String, Object> delegate, 
String fingerprint)
+  {
+    return Map.of(
+        TYPE_FIELD, TYPE,
+        DELEGATE_FIELD, delegate,
+        FINGERPRINT_FIELD, fingerprint
+    );
+  }
+
+  @JsonCreator
+  public PartialFullSegmentLoadSpec(
+      @JsonProperty("delegate") Map<String, Object> delegate,
+      @JsonProperty("fingerprint") String fingerprint,
+      @JacksonInject ObjectMapper jsonMapper
+  )
+  {
+    super(delegate, fingerprint, jsonMapper);
+  }
+
+  /**
+   * Every bundle the segment carries, in container order. No layout branching 
is needed: whatever the writer put in
+   * the file is what gets selected, so this works the same for clustered, 
projection-bearing, plain and legacy
+   * root-only segments alike.
+   */
+  @Override
+  public List<String> getSelectedBundleNames(DataSegment segment, 
SegmentFileMetadata metadata)
+  {
+    final Set<String> present = presentBundleNames(metadata);
+    if (present.isEmpty()) {
+      throw DruidException.defensive(
+          "Cannot resolve full-segment bundles for segment[%s]: metadata 
declares no containers",
+          segment.getId()
+      );
+    }
+    return List.copyOf(present);
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    PartialFullSegmentLoadSpec that = (PartialFullSegmentLoadSpec) o;
+    return Objects.equals(getDelegate(), that.getDelegate())
+        && Objects.equals(getFingerprint(), that.getFingerprint());
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Objects.hash(getDelegate(), getFingerprint());
+  }
+
+  @Override
+  public String toString()
+  {
+    return "PartialFullSegmentLoadSpec{" +
+           "delegate=" + getDelegate() +
+           ", fingerprint=" + getFingerprint() +
+           '}';
+  }
+}
diff --git 
a/processing/src/main/java/org/apache/druid/segment/loading/PartialLoadSpec.java
 
b/processing/src/main/java/org/apache/druid/segment/loading/PartialLoadSpec.java
index 0e3174647c3..d9fc7de3771 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/loading/PartialLoadSpec.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/loading/PartialLoadSpec.java
@@ -24,14 +24,17 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.base.Preconditions;
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
+import org.apache.druid.segment.file.SegmentFileContainerMetadata;
 import org.apache.druid.segment.file.SegmentFileMetadata;
 import org.apache.druid.timeline.DataSegment;
 
 import javax.annotation.Nullable;
 import java.io.File;
 import java.io.IOException;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * Base for {@link LoadSpec} wrappers that carry partial-load metadata (a 
fingerprint identifying the request the
@@ -167,4 +170,16 @@ public abstract class PartialLoadSpec implements LoadSpec
    * bootstrap re-discovers the segment).
    */
   public abstract List<String> getSelectedBundleNames(DataSegment segment, 
SegmentFileMetadata metadata);
+
+  /**
+   * The distinct bundle names carried by {@code metadata}, in container order.
+   */
+  protected static Set<String> presentBundleNames(SegmentFileMetadata metadata)
+  {
+    final Set<String> names = new LinkedHashSet<>();
+    for (SegmentFileContainerMetadata container : metadata.getContainers()) {
+      names.add(container.getBundle());
+    }
+    return names;
+  }
 }
diff --git 
a/processing/src/test/java/org/apache/druid/segment/loading/PartialBaseTableLoadSpecTest.java
 
b/processing/src/test/java/org/apache/druid/segment/loading/PartialBaseTableLoadSpecTest.java
new file mode 100644
index 00000000000..9c06a1dbd47
--- /dev/null
+++ 
b/processing/src/test/java/org/apache/druid/segment/loading/PartialBaseTableLoadSpecTest.java
@@ -0,0 +1,432 @@
+/*
+ * 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.druid.segment.loading;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.InjectableValues;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.google.common.collect.ImmutableMap;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.jackson.DefaultObjectMapper;
+import org.apache.druid.java.util.common.Intervals;
+import org.apache.druid.query.OrderBy;
+import org.apache.druid.query.aggregation.AggregatorFactory;
+import org.apache.druid.query.aggregation.CountAggregatorFactory;
+import org.apache.druid.segment.VirtualColumns;
+import org.apache.druid.segment.column.ColumnHolder;
+import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.segment.column.RowSignature;
+import org.apache.druid.segment.file.SegmentFileBuilder;
+import org.apache.druid.segment.file.SegmentFileContainerMetadata;
+import org.apache.druid.segment.file.SegmentFileMetadata;
+import org.apache.druid.segment.projections.AggregateProjectionSchema;
+import 
org.apache.druid.segment.projections.ClusteredValueGroupsBaseTableSchema;
+import org.apache.druid.segment.projections.ClusteringDictionaries;
+import org.apache.druid.segment.projections.ProjectionMetadata;
+import org.apache.druid.segment.projections.Projections;
+import org.apache.druid.segment.projections.TableClusterGroupSpec;
+import org.apache.druid.segment.projections.TableProjectionSchema;
+import org.apache.druid.timeline.DataSegment;
+import org.apache.druid.timeline.SegmentId;
+import org.apache.druid.timeline.partition.NumberedShardSpec;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import javax.annotation.Nullable;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+class PartialBaseTableLoadSpecTest
+{
+  private static final Map<String, Object> DELEGATE = ImmutableMap.of(
+      "type", "stub",
+      "path", "/var/druid/segments/foo"
+  );
+
+  private static ObjectMapper configuredMapper()
+  {
+    final ObjectMapper m = new DefaultObjectMapper();
+    final SimpleModule module = new SimpleModule();
+    module.registerSubtypes(PartialBaseTableLoadSpec.class, 
StubLoadSpec.class);
+    m.registerModule(module);
+    m.setInjectableValues(new 
InjectableValues.Std().addValue(ObjectMapper.class, m));
+    return m;
+  }
+
+  private final ObjectMapper jsonMapper = configuredMapper();
+
+  @Test
+  void testJsonRoundTrip() throws Exception
+  {
+    PartialBaseTableLoadSpec spec = new PartialBaseTableLoadSpec(
+        DELEGATE,
+        PartialBaseTableLoadSpec.FINGERPRINT,
+        jsonMapper
+    );
+    String json = jsonMapper.writeValueAsString(spec);
+    LoadSpec reread = jsonMapper.readValue(json, LoadSpec.class);
+    Assertions.assertInstanceOf(PartialBaseTableLoadSpec.class, reread);
+    Assertions.assertEquals(spec, reread);
+  }
+
+  @Test
+  void testWireFormHasPartialBaseTableType() throws Exception
+  {
+    PartialBaseTableLoadSpec spec = new PartialBaseTableLoadSpec(
+        DELEGATE,
+        PartialBaseTableLoadSpec.FINGERPRINT,
+        jsonMapper
+    );
+    Map<String, Object> wireForm = jsonMapper.readValue(
+        jsonMapper.writeValueAsString(spec),
+        new TypeReference<>()
+        {
+        }
+    );
+    Assertions.assertEquals("partialBaseTable", wireForm.get("type"));
+    Assertions.assertEquals(DELEGATE, wireForm.get("delegate"));
+    Assertions.assertEquals(PartialBaseTableLoadSpec.FINGERPRINT, 
wireForm.get("fingerprint"));
+    // No scheme-specific field: "the base table" is resolved from layout on 
the historical.
+    Assertions.assertEquals(3, wireForm.size());
+  }
+
+  @Test
+  void testFingerprintIsNotTheEmptyLoadSentinel()
+  {
+    // A base-table load puts every row on the historical; an empty load puts 
nothing there. Sharing a fingerprint
+    // would make a rule swap between the two invisible to the coordinator, 
and the load would never be re-issued.
+    Assertions.assertNotEquals("v1:partial-empty", 
PartialBaseTableLoadSpec.FINGERPRINT);
+  }
+
+  @Test
+  void testGetSelectedBundleNamesUnclusteredSegmentSelectsBase()
+  {
+    final SegmentFileMetadata metadata = metadata(
+        List.of(Projections.BASE_TABLE_PROJECTION_NAME, "user_hourly"),
+        List.of(unclusteredBaseProjection(), projection("user_hourly"))
+    );
+    Assertions.assertEquals(
+        List.of(Projections.BASE_TABLE_PROJECTION_NAME),
+        spec().getSelectedBundleNames(anySegment(), metadata)
+    );
+  }
+
+  @Test
+  void testGetSelectedBundleNamesSegmentWithNoProjectionsSelectsBase()
+  {
+    final SegmentFileMetadata metadata = 
metadata(List.of(Projections.BASE_TABLE_PROJECTION_NAME), null);
+    Assertions.assertEquals(
+        List.of(Projections.BASE_TABLE_PROJECTION_NAME),
+        spec().getSelectedBundleNames(anySegment(), metadata)
+    );
+  }
+
+  @Test
+  void testGetSelectedBundleNamesClusteredSegmentSelectsEveryGroup()
+  {
+    // On a clustered segment the rows are spread across the group bundles, so 
"the base table" is all of them, plus
+    // the shared __base bundle when the segment carries one.
+    final List<TableClusterGroupSpec> groups = List.of(
+        new TableClusterGroupSpec(List.of(0), 10),
+        new TableClusterGroupSpec(List.of(1), 20),
+        new TableClusterGroupSpec(List.of(2), 30)
+    );
+    final List<String> groupBundles = groups.stream()
+                                            .map(g -> 
Projections.getClusterGroupBundleName(g.getClusteringValueIds()))
+                                            .toList();
+    final List<String> present = new ArrayList<>();
+    present.add(Projections.BASE_TABLE_PROJECTION_NAME);
+    present.addAll(groupBundles);
+    final SegmentFileMetadata metadata = metadata(present, 
List.of(clusteredBaseProjection(groups)));
+
+    final List<String> expected = new ArrayList<>();
+    expected.add(Projections.BASE_TABLE_PROJECTION_NAME);
+    expected.addAll(groupBundles);
+    Assertions.assertEquals(expected, 
spec().getSelectedBundleNames(anySegment(), metadata));
+  }
+
+  @Test
+  void testGetSelectedBundleNamesClusteredSegmentWithoutSharedBaseBundle()
+  {
+    // __base is optional on a clustered segment: it only exists once there 
are shared column parts to put in it.
+    final List<TableClusterGroupSpec> groups = List.of(
+        new TableClusterGroupSpec(List.of(0), 10),
+        new TableClusterGroupSpec(List.of(1), 20)
+    );
+    final List<String> groupBundles = groups.stream()
+                                            .map(g -> 
Projections.getClusterGroupBundleName(g.getClusteringValueIds()))
+                                            .toList();
+    final SegmentFileMetadata metadata = metadata(groupBundles, 
List.of(clusteredBaseProjection(groups)));
+    Assertions.assertEquals(groupBundles, 
spec().getSelectedBundleNames(anySegment(), metadata));
+  }
+
+  @Test
+  void testGetSelectedBundleNamesClusteredSegmentExcludesProjectionBundles()
+  {
+    // The base table is the rows, not the precomputation over them.
+    final List<TableClusterGroupSpec> groups = List.of(new 
TableClusterGroupSpec(List.of(0), 10));
+    final String groupBundle = 
Projections.getClusterGroupBundleName(List.of(0));
+    final SegmentFileMetadata metadata = metadata(
+        List.of(Projections.BASE_TABLE_PROJECTION_NAME, groupBundle, 
"user_hourly"),
+        List.of(clusteredBaseProjection(groups), projection("user_hourly"))
+    );
+    Assertions.assertEquals(
+        List.of(Projections.BASE_TABLE_PROJECTION_NAME, groupBundle),
+        spec().getSelectedBundleNames(anySegment(), metadata)
+    );
+  }
+
+  @Test
+  void testGetSelectedBundleNamesLegacyRootOnlySegment()
+  {
+    // A V10 segment written before the bundle name was persisted reports 
everything under __root__, which is then the
+    // whole segment. Gated on root being the sole bundle, matching 
PartialSegmentBundleCacheEntry#resolveBundleName.
+    final SegmentFileMetadata metadata = 
metadata(List.of(SegmentFileBuilder.ROOT_BUNDLE_NAME), null);
+    Assertions.assertEquals(
+        List.of(SegmentFileBuilder.ROOT_BUNDLE_NAME),
+        spec().getSelectedBundleNames(anySegment(), metadata)
+    );
+  }
+
+  @Test
+  void testGetSelectedBundleNamesThrowsWhenNoBaseBundlePresent()
+  {
+    // Named bundles but no __base and no clustering: the reader would fail 
loudly on the acquire anyway, so say so
+    // here where the message can name the cause.
+    final SegmentFileMetadata metadata = metadata(List.of("user_hourly"), 
List.of(projection("user_hourly")));
+    final DruidException thrown = Assertions.assertThrows(
+        DruidException.class,
+        () -> spec().getSelectedBundleNames(anySegment(), metadata)
+    );
+    Assertions.assertTrue(
+        thrown.getMessage().contains("no [__base] bundle among"),
+        "unexpected message: " + thrown.getMessage()
+    );
+  }
+
+  @Test
+  void testGetSelectedBundleNamesThrowsWhenDeclaredGroupBundleMissing()
+  {
+    // Metadata declares two cluster groups but the segment only carries one 
group bundle: writer/reader drift.
+    final List<TableClusterGroupSpec> groups = List.of(
+        new TableClusterGroupSpec(List.of(0), 10),
+        new TableClusterGroupSpec(List.of(1), 20)
+    );
+    final SegmentFileMetadata metadata = metadata(
+        List.of(Projections.getClusterGroupBundleName(List.of(0))),
+        List.of(clusteredBaseProjection(groups))
+    );
+    final DruidException thrown = Assertions.assertThrows(
+        DruidException.class,
+        () -> spec().getSelectedBundleNames(anySegment(), metadata)
+    );
+    Assertions.assertTrue(
+        thrown.getMessage().contains("metadata declares cluster-group bundle"),
+        "unexpected message: " + thrown.getMessage()
+    );
+  }
+
+  @Test
+  void testLoadSegmentDelegatesToInner() throws Exception
+  {
+    StubLoadSpec.LOAD_CALLS.set(0);
+    LoadSpec.LoadSpecResult result = spec().loadSegment(new File("/tmp/dest"));
+    Assertions.assertEquals(1, StubLoadSpec.LOAD_CALLS.get());
+    Assertions.assertEquals(42L, result.getSize());
+  }
+
+  @Test
+  void testOpenRangeReaderDelegatesToInner() throws Exception
+  {
+    StubLoadSpec.RANGE_CALLS.set(0);
+    SegmentRangeReader reader = spec().openRangeReader();
+    Assertions.assertNotNull(reader);
+    Assertions.assertEquals(1, StubLoadSpec.RANGE_CALLS.get());
+  }
+
+  @Test
+  void testOpenRangeReaderReturnsNullWhenInnerDoesNotSupport() throws Exception
+  {
+    PartialBaseTableLoadSpec spec = new PartialBaseTableLoadSpec(
+        ImmutableMap.of("type", "stub", "path", "/", "supportsRange", false),
+        PartialBaseTableLoadSpec.FINGERPRINT,
+        jsonMapper
+    );
+    Assertions.assertNull(spec.openRangeReader());
+  }
+
+  @Test
+  void testRejectsNullDelegate()
+  {
+    Assertions.assertThrows(
+        NullPointerException.class,
+        () -> new PartialBaseTableLoadSpec(null, 
PartialBaseTableLoadSpec.FINGERPRINT, jsonMapper)
+    );
+  }
+
+  @Test
+  void testRejectsNullFingerprint()
+  {
+    Assertions.assertThrows(
+        NullPointerException.class,
+        () -> new PartialBaseTableLoadSpec(DELEGATE, null, jsonMapper)
+    );
+  }
+
+  private PartialBaseTableLoadSpec spec()
+  {
+    return new PartialBaseTableLoadSpec(DELEGATE, 
PartialBaseTableLoadSpec.FINGERPRINT, jsonMapper);
+  }
+
+  private static final RowSignature CLUSTERING_TENANT = RowSignature.builder()
+                                                                    
.add("tenant", ColumnType.STRING)
+                                                                    .build();
+
+  /**
+   * Metadata carrying one container per entry of {@code bundleNames} — the 
base-table spec only reads which bundles
+   * exist, not what is in them — plus the given projection list.
+   */
+  private static SegmentFileMetadata metadata(
+      List<String> bundleNames,
+      @Nullable List<ProjectionMetadata> projections
+  )
+  {
+    final List<SegmentFileContainerMetadata> containers = new 
ArrayList<>(bundleNames.size());
+    long offset = 0;
+    for (String bundleName : bundleNames) {
+      containers.add(new SegmentFileContainerMetadata(offset, 100L, 
bundleName));
+      offset += 100L;
+    }
+    return new SegmentFileMetadata(containers, Map.of(), null, null, null, 
projections, null);
+  }
+
+  private static ProjectionMetadata unclusteredBaseProjection()
+  {
+    return new ProjectionMetadata(
+        100,
+        new TableProjectionSchema(
+            VirtualColumns.EMPTY,
+            List.of(ColumnHolder.TIME_COLUMN_NAME, "tenant"),
+            null,
+            List.of(OrderBy.ascending(ColumnHolder.TIME_COLUMN_NAME))
+        )
+    );
+  }
+
+  private static ProjectionMetadata 
clusteredBaseProjection(List<TableClusterGroupSpec> groups)
+  {
+    return new ProjectionMetadata(
+        groups.stream().mapToInt(TableClusterGroupSpec::getNumRows).sum(),
+        new ClusteredValueGroupsBaseTableSchema(
+            VirtualColumns.EMPTY,
+            List.of(ColumnHolder.TIME_COLUMN_NAME, "tenant", "metric"),
+            List.of(OrderBy.ascending("tenant"), 
OrderBy.ascending(ColumnHolder.TIME_COLUMN_NAME)),
+            CLUSTERING_TENANT,
+            null,
+            new ClusteringDictionaries(List.of("acme", "globex", "initech"), 
null, null, null),
+            groups
+        )
+    );
+  }
+
+  private static ProjectionMetadata projection(String name)
+  {
+    return new ProjectionMetadata(
+        10,
+        new AggregateProjectionSchema(
+            name,
+            null,
+            null,
+            VirtualColumns.EMPTY,
+            List.of("tenant"),
+            new AggregatorFactory[]{new CountAggregatorFactory("cnt")},
+            List.of(OrderBy.ascending("tenant"))
+        )
+    );
+  }
+
+  private static DataSegment anySegment()
+  {
+    return DataSegment.builder(SegmentId.of("ds", Intervals.ETERNITY, "v1", 
new NumberedShardSpec(0, 1)))
+                      .size(0)
+                      .build();
+  }
+
+  /**
+   * Stub LoadSpec used to verify delegation. Uses the same JSON 
"type"=="stub" key as the test {@link #DELEGATE}.
+   */
+  @JsonTypeName("stub")
+  public static class StubLoadSpec implements LoadSpec
+  {
+    static final AtomicInteger LOAD_CALLS = new AtomicInteger(0);
+    static final AtomicInteger RANGE_CALLS = new AtomicInteger(0);
+
+    private final String path;
+    private final boolean supportsRange;
+
+    @JsonCreator
+    public StubLoadSpec(
+        @JsonProperty("path") String path,
+        @JsonProperty("supportsRange") @Nullable Boolean supportsRange
+    )
+    {
+      this.path = path;
+      this.supportsRange = supportsRange == null || supportsRange;
+    }
+
+    @JsonProperty
+    public String getPath()
+    {
+      return path;
+    }
+
+    @JsonProperty
+    public boolean isSupportsRange()
+    {
+      return supportsRange;
+    }
+
+    @Override
+    public LoadSpecResult loadSegment(File destDir)
+    {
+      LOAD_CALLS.incrementAndGet();
+      return new LoadSpecResult(42L);
+    }
+
+    @Override
+    @Nullable
+    public SegmentRangeReader openRangeReader()
+    {
+      if (!supportsRange) {
+        return null;
+      }
+      RANGE_CALLS.incrementAndGet();
+      return (filename, offset, length) -> new ByteArrayInputStream(new 
byte[0]);
+    }
+  }
+}
diff --git 
a/processing/src/test/java/org/apache/druid/segment/loading/PartialFullSegmentLoadSpecTest.java
 
b/processing/src/test/java/org/apache/druid/segment/loading/PartialFullSegmentLoadSpecTest.java
new file mode 100644
index 00000000000..b67e9e0a7e2
--- /dev/null
+++ 
b/processing/src/test/java/org/apache/druid/segment/loading/PartialFullSegmentLoadSpecTest.java
@@ -0,0 +1,277 @@
+/*
+ * 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.druid.segment.loading;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.InjectableValues;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.google.common.collect.ImmutableMap;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.jackson.DefaultObjectMapper;
+import org.apache.druid.java.util.common.Intervals;
+import org.apache.druid.segment.file.SegmentFileBuilder;
+import org.apache.druid.segment.file.SegmentFileContainerMetadata;
+import org.apache.druid.segment.file.SegmentFileMetadata;
+import org.apache.druid.segment.projections.Projections;
+import org.apache.druid.timeline.DataSegment;
+import org.apache.druid.timeline.SegmentId;
+import org.apache.druid.timeline.partition.NumberedShardSpec;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import javax.annotation.Nullable;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+class PartialFullSegmentLoadSpecTest
+{
+  private static final Map<String, Object> DELEGATE = ImmutableMap.of(
+      "type", "stub",
+      "path", "/var/druid/segments/foo"
+  );
+
+  private static ObjectMapper configuredMapper()
+  {
+    final ObjectMapper m = new DefaultObjectMapper();
+    final SimpleModule module = new SimpleModule();
+    module.registerSubtypes(PartialFullSegmentLoadSpec.class, 
StubLoadSpec.class);
+    m.registerModule(module);
+    m.setInjectableValues(new 
InjectableValues.Std().addValue(ObjectMapper.class, m));
+    return m;
+  }
+
+  private final ObjectMapper jsonMapper = configuredMapper();
+
+  @Test
+  void testJsonRoundTrip() throws Exception
+  {
+    PartialFullSegmentLoadSpec spec = spec();
+    String json = jsonMapper.writeValueAsString(spec);
+    LoadSpec reread = jsonMapper.readValue(json, LoadSpec.class);
+    Assertions.assertInstanceOf(PartialFullSegmentLoadSpec.class, reread);
+    Assertions.assertEquals(spec, reread);
+  }
+
+  @Test
+  void testWireFormHasPartialFullSegmentType() throws Exception
+  {
+    Map<String, Object> wireForm = jsonMapper.readValue(
+        jsonMapper.writeValueAsString(spec()),
+        new TypeReference<>()
+        {
+        }
+    );
+    Assertions.assertEquals("partialFullSegment", wireForm.get("type"));
+    Assertions.assertEquals(DELEGATE, wireForm.get("delegate"));
+    Assertions.assertEquals(PartialFullSegmentLoadSpec.FINGERPRINT, 
wireForm.get("fingerprint"));
+    // No scheme-specific field: the selection is the segment's whole layout.
+    Assertions.assertEquals(3, wireForm.size());
+  }
+
+  @Test
+  void testFingerprintIsDistinctFromOtherLayoutDerivedLoads()
+  {
+    // A full-segment load and a base-table load put different amounts on the 
historical, and an empty load puts
+    // nothing there. Sharing a fingerprint would hide a rule swap from the 
coordinator's reconciliation.
+    Assertions.assertNotEquals(PartialBaseTableLoadSpec.FINGERPRINT, 
PartialFullSegmentLoadSpec.FINGERPRINT);
+    Assertions.assertNotEquals("v1:partial-empty", 
PartialFullSegmentLoadSpec.FINGERPRINT);
+  }
+
+  @Test
+  void testGetSelectedBundleNamesSelectsEveryBundle()
+  {
+    // Base table, a cluster group and a projection: all of them, no layout 
branching.
+    final String groupBundle = 
Projections.getClusterGroupBundleName(List.of(0));
+    final List<String> bundles = 
List.of(Projections.BASE_TABLE_PROJECTION_NAME, groupBundle, "user_hourly");
+    Assertions.assertEquals(bundles, 
spec().getSelectedBundleNames(anySegment(), metadata(bundles)));
+  }
+
+  @Test
+  void testGetSelectedBundleNamesDedupesContainersSharingABundle()
+  {
+    // A bundle spans as many containers as the writer needed; the selection 
is by name.
+    final SegmentFileMetadata metadata = metadata(
+        List.of(Projections.BASE_TABLE_PROJECTION_NAME, 
Projections.BASE_TABLE_PROJECTION_NAME, "user_hourly")
+    );
+    Assertions.assertEquals(
+        List.of(Projections.BASE_TABLE_PROJECTION_NAME, "user_hourly"),
+        spec().getSelectedBundleNames(anySegment(), metadata)
+    );
+  }
+
+  @Test
+  void testGetSelectedBundleNamesLegacyRootOnlySegment()
+  {
+    final SegmentFileMetadata metadata = 
metadata(List.of(SegmentFileBuilder.ROOT_BUNDLE_NAME));
+    Assertions.assertEquals(
+        List.of(SegmentFileBuilder.ROOT_BUNDLE_NAME),
+        spec().getSelectedBundleNames(anySegment(), metadata)
+    );
+  }
+
+  @Test
+  void testGetSelectedBundleNamesThrowsWhenNoContainers()
+  {
+    final SegmentFileMetadata metadata = metadata(List.of());
+    final DruidException thrown = Assertions.assertThrows(
+        DruidException.class,
+        () -> spec().getSelectedBundleNames(anySegment(), metadata)
+    );
+    Assertions.assertTrue(
+        thrown.getMessage().contains("metadata declares no containers"),
+        "unexpected message: " + thrown.getMessage()
+    );
+  }
+
+  @Test
+  void testLoadSegmentDelegatesToInner() throws Exception
+  {
+    StubLoadSpec.LOAD_CALLS.set(0);
+    LoadSpec.LoadSpecResult result = spec().loadSegment(new File("/tmp/dest"));
+    Assertions.assertEquals(1, StubLoadSpec.LOAD_CALLS.get());
+    Assertions.assertEquals(42L, result.getSize());
+  }
+
+  @Test
+  void testOpenRangeReaderDelegatesToInner() throws Exception
+  {
+    StubLoadSpec.RANGE_CALLS.set(0);
+    SegmentRangeReader reader = spec().openRangeReader();
+    Assertions.assertNotNull(reader);
+    Assertions.assertEquals(1, StubLoadSpec.RANGE_CALLS.get());
+  }
+
+  @Test
+  void testOpenRangeReaderReturnsNullWhenInnerDoesNotSupport() throws Exception
+  {
+    PartialFullSegmentLoadSpec spec = new PartialFullSegmentLoadSpec(
+        ImmutableMap.of("type", "stub", "path", "/", "supportsRange", false),
+        PartialFullSegmentLoadSpec.FINGERPRINT,
+        jsonMapper
+    );
+    Assertions.assertNull(spec.openRangeReader());
+  }
+
+  @Test
+  void testRejectsNullDelegate()
+  {
+    Assertions.assertThrows(
+        NullPointerException.class,
+        () -> new PartialFullSegmentLoadSpec(null, 
PartialFullSegmentLoadSpec.FINGERPRINT, jsonMapper)
+    );
+  }
+
+  @Test
+  void testRejectsNullFingerprint()
+  {
+    Assertions.assertThrows(
+        NullPointerException.class,
+        () -> new PartialFullSegmentLoadSpec(DELEGATE, null, jsonMapper)
+    );
+  }
+
+  private PartialFullSegmentLoadSpec spec()
+  {
+    return new PartialFullSegmentLoadSpec(DELEGATE, 
PartialFullSegmentLoadSpec.FINGERPRINT, jsonMapper);
+  }
+
+  /**
+   * Metadata carrying one container per entry of {@code bundleNames} — this 
spec only reads which bundles exist, not
+   * what is in them, and no projection list is needed since it does no layout 
branching.
+   */
+  private static SegmentFileMetadata metadata(List<String> bundleNames)
+  {
+    final List<SegmentFileContainerMetadata> containers = new 
ArrayList<>(bundleNames.size());
+    long offset = 0;
+    for (String bundleName : bundleNames) {
+      containers.add(new SegmentFileContainerMetadata(offset, 100L, 
bundleName));
+      offset += 100L;
+    }
+    return new SegmentFileMetadata(containers, Map.of(), null, null, null, 
null, null);
+  }
+
+  private static DataSegment anySegment()
+  {
+    return DataSegment.builder(SegmentId.of("ds", Intervals.ETERNITY, "v1", 
new NumberedShardSpec(0, 1)))
+                      .size(0)
+                      .build();
+  }
+
+  /**
+   * Stub LoadSpec used to verify delegation. Uses the same JSON 
"type"=="stub" key as the test {@link #DELEGATE}.
+   */
+  @JsonTypeName("stub")
+  public static class StubLoadSpec implements LoadSpec
+  {
+    static final AtomicInteger LOAD_CALLS = new AtomicInteger(0);
+    static final AtomicInteger RANGE_CALLS = new AtomicInteger(0);
+
+    private final String path;
+    private final boolean supportsRange;
+
+    @JsonCreator
+    public StubLoadSpec(
+        @JsonProperty("path") String path,
+        @JsonProperty("supportsRange") @Nullable Boolean supportsRange
+    )
+    {
+      this.path = path;
+      this.supportsRange = supportsRange == null || supportsRange;
+    }
+
+    @JsonProperty
+    public String getPath()
+    {
+      return path;
+    }
+
+    @JsonProperty
+    public boolean isSupportsRange()
+    {
+      return supportsRange;
+    }
+
+    @Override
+    public LoadSpecResult loadSegment(File destDir)
+    {
+      LOAD_CALLS.incrementAndGet();
+      return new LoadSpecResult(42L);
+    }
+
+    @Override
+    @Nullable
+    public SegmentRangeReader openRangeReader()
+    {
+      if (!supportsRange) {
+        return null;
+      }
+      RANGE_CALLS.incrementAndGet();
+      return (filename, offset, length) -> new ByteArrayInputStream(new 
byte[0]);
+    }
+  }
+}
diff --git 
a/server/src/main/java/org/apache/druid/guice/PartialLoadSpecModule.java 
b/server/src/main/java/org/apache/druid/guice/PartialLoadSpecModule.java
index e3b615738fd..d2a004a25c6 100644
--- a/server/src/main/java/org/apache/druid/guice/PartialLoadSpecModule.java
+++ b/server/src/main/java/org/apache/druid/guice/PartialLoadSpecModule.java
@@ -25,15 +25,17 @@ import com.google.inject.Binder;
 import org.apache.druid.initialization.DruidModule;
 import org.apache.druid.segment.loading.CompositePartialLoadSpec;
 import org.apache.druid.segment.loading.LoadSpec;
+import org.apache.druid.segment.loading.PartialBaseTableLoadSpec;
 import org.apache.druid.segment.loading.PartialClusterGroupLoadSpec;
+import org.apache.druid.segment.loading.PartialFullSegmentLoadSpec;
 import org.apache.druid.segment.loading.PartialProjectionLoadSpec;
 
 import java.util.List;
 
 /**
- * Registers {@link PartialProjectionLoadSpec}, {@link 
PartialClusterGroupLoadSpec} and
- * {@link CompositePartialLoadSpec} as {@link LoadSpec} subtypes for serde of 
partial load rules. This module is added
- * to the always-loaded core list so they are available alongside any other 
deep-storage load spec modules.
+ * Registers every {@link org.apache.druid.segment.loading.PartialLoadSpec} 
subtype as a {@link LoadSpec} subtype for
+ * serde of partial load rules. This module is added to the always-loaded core 
list so they are available alongside
+ * any other deep-storage load spec modules.
  */
 public class PartialLoadSpecModule implements DruidModule
 {
@@ -50,6 +52,8 @@ public class PartialLoadSpecModule implements DruidModule
         new SimpleModule().registerSubtypes(
             PartialProjectionLoadSpec.class,
             PartialClusterGroupLoadSpec.class,
+            PartialBaseTableLoadSpec.class,
+            PartialFullSegmentLoadSpec.class,
             CompositePartialLoadSpec.class
         )
     );
diff --git 
a/server/src/main/java/org/apache/druid/server/coordinator/rules/CannotMatchBehavior.java
 
b/server/src/main/java/org/apache/druid/server/coordinator/rules/CannotMatchBehavior.java
index 4da44b7361c..0e99a820f1f 100644
--- 
a/server/src/main/java/org/apache/druid/server/coordinator/rules/CannotMatchBehavior.java
+++ 
b/server/src/main/java/org/apache/druid/server/coordinator/rules/CannotMatchBehavior.java
@@ -25,12 +25,15 @@ import com.fasterxml.jackson.annotation.JsonValue;
 import javax.annotation.Nullable;
 
 /**
- * Controls what happens when a {@link PartialLoadRule}'s {@link 
PartialLoadMatcher} does not apply to a given segment,
- * for example, a {@link ProjectionPartialLoadMatcher} when faced with a 
segment that doesn't have projections.
+ * Controls what happens when a {@link PartialLoadRule}'s {@link 
PartialLoadMatcher} does not apply to a given segment.
+ * <p>
+ * Every value other than {@link #FALL_THROUGH} applies the rule; they differ 
in how much of the segment the
+ * historical is asked to make resident. {@link #LOAD_ON_DEMAND} asks for none 
of it up front, {@link #BASE_LOAD} for
+ * the 'base table' (no projections), and {@link #FULL_LOAD} for everything.
  * <p>
  * Unknown values deserialize to {@code null} so that an older coordinator 
encountering a rule authored on a newer
- * version that introduces a new behavior falls back to the constructor's 
default ({@link #FULL_LOAD}) rather than
- * failing to parse the rule.
+ * version that introduces a new behavior falls back to the constructor's 
default ({@link #LOAD_ON_DEMAND}) rather
+ * than failing to parse the rule.
  */
 public enum CannotMatchBehavior
 {
@@ -40,7 +43,23 @@ public enum CannotMatchBehavior
   FALL_THROUGH("fallThrough"),
 
   /**
-   * The rule applies and the segment is loaded in full on this tier.
+   * The rule applies and the segment is dispatched with no partial-load 
wrapper at all. On a virtual-storage
+   * historical that means its bundles are fetched on demand as queries touch 
them, rather than being made resident up
+   * front. This is the default, and the cheapest way to say "this rule covers 
the segment, but don't pre-load anything
+   * specific for it".
+   */
+  LOAD_ON_DEMAND("loadOnDemand"),
+
+  /**
+   * The rule applies and the segment's base table is made resident: every row 
is available, but no projections. The
+   * minimum that keeps the segment fully queryable without waiting on demand 
fetches. See
+   * {@link org.apache.druid.segment.loading.PartialBaseTableLoadSpec}.
+   */
+  BASE_LOAD("baseLoad"),
+
+  /**
+   * The rule applies and every bundle the segment carries is made resident.
+   * See {@link org.apache.druid.segment.loading.PartialFullSegmentLoadSpec}.
    */
   FULL_LOAD("fullLoad");
 
diff --git 
a/server/src/main/java/org/apache/druid/server/coordinator/rules/PartialLoadRule.java
 
b/server/src/main/java/org/apache/druid/server/coordinator/rules/PartialLoadRule.java
index b16942df6d5..d92a75f2ceb 100644
--- 
a/server/src/main/java/org/apache/druid/server/coordinator/rules/PartialLoadRule.java
+++ 
b/server/src/main/java/org/apache/druid/server/coordinator/rules/PartialLoadRule.java
@@ -21,7 +21,10 @@ package org.apache.druid.server.coordinator.rules;
 
 import com.fasterxml.jackson.annotation.JsonProperty;
 import org.apache.druid.common.config.Configs;
+import org.apache.druid.error.DruidException;
 import org.apache.druid.error.InvalidInput;
+import org.apache.druid.segment.loading.PartialBaseTableLoadSpec;
+import org.apache.druid.segment.loading.PartialFullSegmentLoadSpec;
 import org.apache.druid.server.coordinator.loading.PartialLoadProfile;
 import org.apache.druid.timeline.DataSegment;
 import org.joda.time.DateTime;
@@ -32,9 +35,9 @@ import java.util.Objects;
 
 /**
  * Base class for rules that load only a subset of a segment on a tier. Pairs 
a {@link PartialLoadMatcher} (which
- * produces the wrapped load-spec wire form and an accounting fingerprint when 
it applies to a segment) with a
- * {@link CannotMatchBehavior} that controls whether the rule falls through or 
full-loads when the matcher does not
- * apply.
+ * produces the wrapped load spec and an accounting fingerprint when it 
applies to a segment) with a
+ * {@link CannotMatchBehavior} that decides what the rule does when the 
matcher does not apply — fall through to the
+ * next rule, or apply anyway and ask for none, some, or all of the segment.
  */
 public abstract class PartialLoadRule extends LoadRule
 {
@@ -53,7 +56,7 @@ public abstract class PartialLoadRule extends LoadRule
       throw InvalidInput.exception("matcher must not be null for a partial 
load rule");
     }
     this.matcher = matcher;
-    this.onCannotMatch = Configs.valueOrDefault(onCannotMatch, 
CannotMatchBehavior.FULL_LOAD);
+    this.onCannotMatch = Configs.valueOrDefault(onCannotMatch, 
CannotMatchBehavior.LOAD_ON_DEMAND);
   }
 
   @JsonProperty
@@ -84,7 +87,9 @@ public abstract class PartialLoadRule extends LoadRule
     if (result != null) {
       return true;
     }
-    return onCannotMatch == CannotMatchBehavior.FULL_LOAD;
+    // Every behavior other than FALL_THROUGH applies the rule; they differ 
only in how much of the segment run()
+    // then asks the historical to make resident.
+    return onCannotMatch != CannotMatchBehavior.FALL_THROUGH;
   }
 
   @Override
@@ -99,12 +104,49 @@ public abstract class PartialLoadRule extends LoadRule
           PartialLoadProfile.forRequest(result.wrappedLoadSpec(), 
result.fingerprint()),
           getTieredReplicants()
       );
-    } else {
-      // Matcher does not apply, but the rule still applies because 
onCannotMatch == FULL_LOAD (FALL_THROUGH would
-      // have caused appliesTo to return false, so run wouldn't be invoked). 
Route through the regular full-load
-      // handler.
-      handler.replicateSegment(segment, getTieredReplicants());
+      return;
     }
+    // Matcher does not apply, but the rule still does — FALL_THROUGH would 
have made appliesTo return false, so run
+    // wouldn't have been invoked. How much of the segment to make resident is 
onCannotMatch's call.
+    switch (onCannotMatch) {
+      case LOAD_ON_DEMAND -> handler.replicateSegment(segment, 
getTieredReplicants());
+      case BASE_LOAD -> replicateWholly(
+          segment,
+          handler,
+          PartialBaseTableLoadSpec.wireForm(segment.getLoadSpec(), 
PartialBaseTableLoadSpec.FINGERPRINT),
+          PartialBaseTableLoadSpec.FINGERPRINT
+      );
+      case FULL_LOAD -> replicateWholly(
+          segment,
+          handler,
+          PartialFullSegmentLoadSpec.wireForm(segment.getLoadSpec(), 
PartialFullSegmentLoadSpec.FINGERPRINT),
+          PartialFullSegmentLoadSpec.FINGERPRINT
+      );
+      default -> throw DruidException.defensive(
+          "Unreachable onCannotMatch[%s] in run() for segment[%s]; appliesTo 
should have returned false",
+          onCannotMatch,
+          segment.getId()
+      );
+    }
+  }
+
+  /**
+   * Dispatches a layout-derived partial load — one whose selection is "the 
base table" or "everything" rather than
+   * anything the matcher resolved. These still go through the partial-load 
handler so the historical actually makes
+   * the bundles resident and announces a fingerprint the coordinator can 
reconcile.
+   */
+  private void replicateWholly(
+      DataSegment segment,
+      SegmentActionHandler handler,
+      Map<String, Object> wrappedLoadSpec,
+      String fingerprint
+  )
+  {
+    handler.replicateSegmentPartially(
+        segment,
+        PartialLoadProfile.forRequest(wrappedLoadSpec, fingerprint),
+        getTieredReplicants()
+    );
   }
 
   @Override
diff --git 
a/server/src/main/java/org/apache/druid/server/coordinator/rules/ProjectionPartialLoadMatcher.java
 
b/server/src/main/java/org/apache/druid/server/coordinator/rules/ProjectionPartialLoadMatcher.java
index a510ed540e3..982c3d73499 100644
--- 
a/server/src/main/java/org/apache/druid/server/coordinator/rules/ProjectionPartialLoadMatcher.java
+++ 
b/server/src/main/java/org/apache/druid/server/coordinator/rules/ProjectionPartialLoadMatcher.java
@@ -22,10 +22,10 @@ package org.apache.druid.server.coordinator.rules;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
 import com.google.common.io.BaseEncoding;
+import org.apache.druid.segment.loading.PartialBaseTableLoadSpec;
 import org.apache.druid.segment.loading.PartialProjectionLoadSpec;
 import org.apache.druid.timeline.DataSegment;
 
-import javax.annotation.Nullable;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
@@ -33,12 +33,23 @@ import java.util.Map;
 /**
  * Base for {@link PartialLoadMatcher} implementations that decide which of a 
segment's V10 projections to load.
  * Subclasses supply the resolution policy via {@link 
#resolveProjectionNames(DataSegment)}; this base handles
- * fingerprint computation and wraps the result into the {@code 
partialProjection} load-spec wire form consumed
- * by the historical-side {@link PartialProjectionLoadSpec}.
+ * fingerprint computation and wraps the result into the {@code 
partialProjection} load spec consumed by the
+ * historical-side {@link PartialProjectionLoadSpec}.
  * <p>
  * The fingerprint is a hash of what projections are partially loaded on a 
segment by this rule; the data node will
  * include this value in the segment announcement so that it can be used as a 
lightweight value to compare against
  * to handle things like rule change so that we can ensure that the 'right' 
partial load is in place from run to run.
+ * <p>
+ * <b>Projection matchers always apply.</b> When none of the configured 
projections are present on a segment, the
+ * matcher resolves to a {@link PartialBaseTableLoadSpec} (every row, no 
projections) instead of going opaque. A
+ * projection is precomputation that is always recoverable from the base 
table, so the base table is a correct
+ * substitute for one the segment doesn't carry, and it is strictly less data 
than every bundle on the segment. This
+ * is the ordinary state of affairs mid-rollout, when a new projection is 
being reindexed in and only some segments
+ * carry it yet.
+ * <p>
+ * Because {@link #match} therefore never returns {@code null}, a rule whose 
only matcher is a projection matcher
+ * never consults its {@link CannotMatchBehavior}, and "segments lacking 
projection p belong to a different rule" is
+ * not expressible via {@link CannotMatchBehavior#FALL_THROUGH}. Scope such 
rules by interval or period instead.
  */
 public abstract class ProjectionPartialLoadMatcher implements 
PartialLoadMatcher
 {
@@ -47,17 +58,23 @@ public abstract class ProjectionPartialLoadMatcher 
implements PartialLoadMatcher
   /**
    * Returns the sorted, deduped list of projection names from {@link 
DataSegment#getProjections()} that this matcher
    * selects. Returns an empty list when nothing matches (the segment exposes 
no projections, or no configured pattern
-   * intersects what the segment has).
+   * intersects what the segment has), which {@link #match} turns into a 
base-table load rather than a non-match.
    */
   protected abstract List<String> resolveProjectionNames(DataSegment segment);
 
+  /**
+   * Never returns {@code null}; see the class doc. Either the resolved 
projections, or a base-table load when none of
+   * them are present on {@code segment}.
+   */
   @Override
-  @Nullable
   public MatchResult match(DataSegment segment, Map<String, Object> 
baseLoadSpec)
   {
     final List<String> resolved = resolveProjectionNames(segment);
     if (resolved.isEmpty()) {
-      return null;
+      return new MatchResult(
+          PartialBaseTableLoadSpec.wireForm(baseLoadSpec, 
PartialBaseTableLoadSpec.FINGERPRINT),
+          PartialBaseTableLoadSpec.FINGERPRINT
+      );
     }
     final String fingerprint = computeFingerprint(resolved);
     return new MatchResult(PartialProjectionLoadSpec.wireForm(baseLoadSpec, 
resolved, fingerprint), fingerprint);
diff --git 
a/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialRuleLoadTest.java
 
b/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialRuleLoadTest.java
index 87be5378926..a0ba9739b9c 100644
--- 
a/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialRuleLoadTest.java
+++ 
b/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialRuleLoadTest.java
@@ -177,6 +177,8 @@ class SegmentLocalCacheManagerPartialRuleLoadTest
     jsonMapper.registerSubtypes(new NamedType(LocalLoadSpec.class, "local"));
     jsonMapper.registerSubtypes(new NamedType(PartialProjectionLoadSpec.class, 
PartialProjectionLoadSpec.TYPE));
     jsonMapper.registerSubtypes(new NamedType(CompositePartialLoadSpec.class, 
CompositePartialLoadSpec.TYPE));
+    jsonMapper.registerSubtypes(new NamedType(PartialBaseTableLoadSpec.class, 
PartialBaseTableLoadSpec.TYPE));
+    jsonMapper.registerSubtypes(new 
NamedType(PartialFullSegmentLoadSpec.class, PartialFullSegmentLoadSpec.TYPE));
     jsonMapper.registerModule(new SegmentizerModule());
     jsonMapper.registerModules(new 
LocalDataStorageDruidModule().getJacksonModules());
     jsonMapper.setInjectableValues(
@@ -233,6 +235,75 @@ class SegmentLocalCacheManagerPartialRuleLoadTest
     Assertions.assertFalse(location.isReserved(aggId), "selected bundle should 
NOT be in staticCacheEntries");
   }
 
+  @Test
+  void testLoadBaseTableWrapperHoldsOnlyTheBaseBundle() throws Exception
+  {
+    // What a projection matcher emits when none of its projections are on the 
segment. This fixture is unclustered,
+    // so the base table is the single __base bundle and none of the 
projections come along for the ride. The
+    // clustered branch (every __base$* group) is covered in 
PartialBaseTableLoadSpecTest.
+    manager = makeManager(true, true);
+    final StorageLocation location = manager.getLocations().get(0);
+
+    manager.load(baseTableWrapperSegment());
+
+    final PartialSegmentMetadataCacheEntry metadata = 
weakReservedMetadata(location, SEGMENT_ID);
+    Assertions.assertTrue(metadata.isRuleHeld(), "rule must be applied to the 
metadata entry");
+    Assertions.assertEquals(PartialBaseTableLoadSpec.FINGERPRINT, 
metadata.getRuleFingerprint());
+    Assertions.assertTrue(
+        metadata.isBundleRuleHeld(Projections.BASE_TABLE_PROJECTION_NAME),
+        "__base should be rule-held by a base-table load"
+    );
+
+    final PartialSegmentFileMapperV10 mapper = metadata.getFileMapper();
+    Assertions.assertNotNull(mapper, "metadata mount should produce a file 
mapper");
+    Assertions.assertTrue(
+        mapper.isBundleFullyDownloaded(Projections.BASE_TABLE_PROJECTION_NAME),
+        "__base must be fully downloaded eagerly"
+    );
+
+    for (String projectionBundle : List.of(AGG_BUNDLE, OTHER_AGG_BUNDLE)) {
+      Assertions.assertFalse(
+          location.isWeakReserved(new 
PartialSegmentBundleCacheEntryIdentifier(SEGMENT_ID, projectionBundle)),
+          "projection bundle[" + projectionBundle + "] must not be reserved by 
a base-table load"
+      );
+    }
+  }
+
+  @Test
+  void testLoadFullSegmentWrapperHoldsEveryBundle() throws Exception
+  {
+    // What CannotMatchBehavior.FULL_LOAD emits. Unlike dispatching with no 
wrapper — which on a virtual-storage
+    // historical only makes the segment available on demand — this pins every 
bundle up front.
+    manager = makeManager(true, true);
+    final StorageLocation location = manager.getLocations().get(0);
+
+    manager.load(fullSegmentWrapperSegment());
+
+    final PartialSegmentMetadataCacheEntry metadata = 
weakReservedMetadata(location, SEGMENT_ID);
+    Assertions.assertTrue(metadata.isRuleHeld(), "rule must be applied to the 
metadata entry");
+    Assertions.assertEquals(PartialFullSegmentLoadSpec.FINGERPRINT, 
metadata.getRuleFingerprint());
+
+    final PartialSegmentFileMapperV10 mapper = metadata.getFileMapper();
+    Assertions.assertNotNull(mapper, "metadata mount should produce a file 
mapper");
+    for (String bundleName : List.of(Projections.BASE_TABLE_PROJECTION_NAME, 
AGG_BUNDLE, OTHER_AGG_BUNDLE)) {
+      Assertions.assertTrue(
+          metadata.isBundleRuleHeld(bundleName),
+          "bundle[" + bundleName + "] should be rule-held by a full-segment 
load"
+      );
+      Assertions.assertTrue(
+          mapper.isBundleFullyDownloaded(bundleName),
+          "bundle[" + bundleName + "] must be fully downloaded eagerly"
+      );
+    }
+    // Nothing on the segment was left out: not just the three bundles named 
above, but every bundle the file carries.
+    for (String bundleName : 
PartialSegmentBundleCacheEntry.bundleNames(mapper)) {
+      Assertions.assertTrue(
+          metadata.isBundleRuleHeld(bundleName),
+          "bundle[" + bundleName + "] present on the segment but not rule-held"
+      );
+    }
+  }
+
   @Test
   void testLoadCompositeWrapperInstallsRuleHoldsOnUnionOfMemberBundles() 
throws Exception
   {
@@ -900,6 +971,43 @@ class SegmentLocalCacheManagerPartialRuleLoadTest
                       .build();
   }
 
+  /**
+   * A {@code partialFullSegment} wrapper, matching what {@code 
CannotMatchBehavior.FULL_LOAD} emits.
+   */
+  private DataSegment fullSegmentWrapperSegment()
+  {
+    final Map<String, Object> delegate = Map.of(
+        "type", "local",
+        "path", DEEP_STORAGE_DIR.getAbsolutePath()
+    );
+    return DataSegment.builder(SEGMENT_ID)
+                      .shardSpec(NoneShardSpec.instance())
+                      .loadSpec(
+                          PartialFullSegmentLoadSpec.wireForm(delegate, 
PartialFullSegmentLoadSpec.FINGERPRINT)
+                      )
+                      .size(0)
+                      .build();
+  }
+
+  /**
+   * A {@code partialBaseTable} wrapper, matching what a projection matcher 
emits when none of its configured
+   * projections are present on the segment.
+   */
+  private DataSegment baseTableWrapperSegment()
+  {
+    final Map<String, Object> delegate = Map.of(
+        "type", "local",
+        "path", DEEP_STORAGE_DIR.getAbsolutePath()
+    );
+    return DataSegment.builder(SEGMENT_ID)
+                      .shardSpec(NoneShardSpec.instance())
+                      .loadSpec(
+                          PartialBaseTableLoadSpec.wireForm(delegate, 
PartialBaseTableLoadSpec.FINGERPRINT)
+                      )
+                      .size(0)
+                      .build();
+  }
+
   /**
    * A {@code partialComposite} wrapper whose members each select one 
projection, matching what
    * {@code CompositePartialLoadMatcher} emits: the delegate lives once at the 
top level and members carry none.
diff --git 
a/server/src/test/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssignerPartialTest.java
 
b/server/src/test/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssignerPartialTest.java
index 7c9acaac794..0fe129bbba8 100644
--- 
a/server/src/test/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssignerPartialTest.java
+++ 
b/server/src/test/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssignerPartialTest.java
@@ -38,6 +38,7 @@ import 
org.apache.druid.server.coordinator.rules.CannotMatchBehavior;
 import 
org.apache.druid.server.coordinator.rules.ExactProjectionPartialLoadMatcher;
 import org.apache.druid.server.coordinator.rules.ForeverPartialLoadRule;
 import org.apache.druid.server.coordinator.rules.PartialLoadRule;
+import 
org.apache.druid.server.coordinator.rules.WildcardClusterGroupPartialLoadMatcher;
 import org.apache.druid.server.coordinator.stats.CoordinatorRunStats;
 import org.apache.druid.server.coordinator.stats.Stats;
 import org.apache.druid.timeline.DataSegment;
@@ -673,10 +674,11 @@ public class StrategicSegmentAssignerPartialTest
   }
 
   @Test
-  public void testForeverPartialLoadRuleEndToEndFullLoadFallback()
+  public void testForeverPartialLoadRuleEndToEndLoadOnDemandFallback()
   {
-    // Matcher does not apply (segment has no overlap); FULL_LOAD 
onCannotMatch (default) → run() routes through
-    // replicateSegment instead, so the peon must not see a profile and the 
stat is the regular ASSIGNED.
+    // Matcher does not apply; LOAD_ON_DEMAND onCannotMatch (the default) → 
run() routes through replicateSegment, so
+    // the peon must not see a profile and the stat is the regular ASSIGNED. A 
cluster-group matcher supplies the
+    // non-match: projection matchers always apply, falling back to a 
base-table load.
     final ServerHolder server = createServer(TIER1);
     final DruidCluster cluster = DruidCluster.builder().addTier(TIER1, 
server).build();
 
@@ -684,8 +686,8 @@ public class StrategicSegmentAssignerPartialTest
     final PartialLoadRule rule = new ForeverPartialLoadRule(
         ImmutableMap.of(TIER1, 1),
         null,
-        new ExactProjectionPartialLoadMatcher(List.of("nonexistent")),
-        CannotMatchBehavior.FULL_LOAD
+        new 
WildcardClusterGroupPartialLoadMatcher(List.of(ImmutableMap.of("tenant", 
"acme")), null),
+        CannotMatchBehavior.LOAD_ON_DEMAND
     );
 
     final DruidCoordinatorRuntimeParams params = makeRuntimeParams(cluster, 
segment);
diff --git 
a/server/src/test/java/org/apache/druid/server/coordinator/rules/ExactProjectionPartialLoadMatcherTest.java
 
b/server/src/test/java/org/apache/druid/server/coordinator/rules/ExactProjectionPartialLoadMatcherTest.java
index ade05388eb1..2fccc989b60 100644
--- 
a/server/src/test/java/org/apache/druid/server/coordinator/rules/ExactProjectionPartialLoadMatcherTest.java
+++ 
b/server/src/test/java/org/apache/druid/server/coordinator/rules/ExactProjectionPartialLoadMatcherTest.java
@@ -26,6 +26,7 @@ import org.apache.druid.error.DruidExceptionMatcher;
 import org.apache.druid.jackson.DefaultObjectMapper;
 import org.apache.druid.java.util.common.DateTimes;
 import org.apache.druid.java.util.common.Intervals;
+import org.apache.druid.segment.loading.PartialBaseTableLoadSpec;
 import org.apache.druid.timeline.DataSegment;
 import org.apache.druid.timeline.SegmentId;
 import org.apache.druid.timeline.partition.NumberedShardSpec;
@@ -79,33 +80,47 @@ public class ExactProjectionPartialLoadMatcherTest
   }
 
   @Test
-  void testMatchReturnsNullWhenNoIntersection()
+  void testMatchFallsBackToBaseTableWhenNoIntersection()
   {
+    // None of the configured projections are on the segment. The base table 
can always answer what a projection
+    // would have, so the matcher asks for that rather than going opaque.
     ExactProjectionPartialLoadMatcher matcher = new 
ExactProjectionPartialLoadMatcher(
         List.of("x", "y")
     );
     DataSegment segment = segmentWithProjections(List.of("a", "b"));
-    Assertions.assertNull(matcher.match(segment, segment.getLoadSpec()));
+    assertBaseTableLoad(matcher.match(segment, segment.getLoadSpec()), 
segment);
   }
 
   @Test
-  void testMatchReturnsNullForProjectionAgnosticSegment()
+  void testMatchFallsBackToBaseTableForProjectionAgnosticSegment()
   {
     ExactProjectionPartialLoadMatcher matcher = new 
ExactProjectionPartialLoadMatcher(
         List.of("a")
     );
     DataSegment segment = segmentWithProjections(null);
-    Assertions.assertNull(matcher.match(segment, segment.getLoadSpec()));
+    assertBaseTableLoad(matcher.match(segment, segment.getLoadSpec()), 
segment);
   }
 
   @Test
-  void testMatchReturnsNullForEmptyProjectionsList()
+  void testMatchFallsBackToBaseTableForEmptyProjectionsList()
   {
     ExactProjectionPartialLoadMatcher matcher = new 
ExactProjectionPartialLoadMatcher(
         List.of("a")
     );
     DataSegment segment = segmentWithProjections(Collections.emptyList());
-    Assertions.assertNull(matcher.match(segment, segment.getLoadSpec()));
+    assertBaseTableLoad(matcher.match(segment, segment.getLoadSpec()), 
segment);
+  }
+
+  @Test
+  void testBaseTableFallbackFingerprintIsStableAcrossConfigurations()
+  {
+    // Two differently-configured matchers that both find nothing produce the 
same load, so they must agree on the
+    // fingerprint or the coordinator would see a rule change that isn't one.
+    DataSegment segment = segmentWithProjections(List.of("a", "b"));
+    Assertions.assertEquals(
+        new ExactProjectionPartialLoadMatcher(List.of("x")).match(segment, 
segment.getLoadSpec()),
+        new ExactProjectionPartialLoadMatcher(List.of("y", 
"z")).match(segment, segment.getLoadSpec())
+    );
   }
 
   @Test
@@ -162,6 +177,16 @@ public class ExactProjectionPartialLoadMatcherTest
     
EqualsVerifier.forClass(ExactProjectionPartialLoadMatcher.class).usingGetClass().verify();
   }
 
+  private static void assertBaseTableLoad(PartialLoadMatcher.MatchResult 
result, DataSegment segment)
+  {
+    Assertions.assertNotNull(result);
+    Assertions.assertEquals(PartialBaseTableLoadSpec.FINGERPRINT, 
result.fingerprint());
+    Assertions.assertEquals(
+        PartialBaseTableLoadSpec.wireForm(segment.getLoadSpec(), 
PartialBaseTableLoadSpec.FINGERPRINT),
+        result.wrappedLoadSpec()
+    );
+  }
+
   private static DataSegment segmentWithProjections(List<String> projections)
   {
     return BUILDER.projections(projections).build();
diff --git 
a/server/src/test/java/org/apache/druid/server/coordinator/rules/PartialLoadRuleTest.java
 
b/server/src/test/java/org/apache/druid/server/coordinator/rules/PartialLoadRuleTest.java
index 35113529c01..66d263b202d 100644
--- 
a/server/src/test/java/org/apache/druid/server/coordinator/rules/PartialLoadRuleTest.java
+++ 
b/server/src/test/java/org/apache/druid/server/coordinator/rules/PartialLoadRuleTest.java
@@ -27,6 +27,8 @@ import org.apache.druid.error.DruidExceptionMatcher;
 import org.apache.druid.jackson.DefaultObjectMapper;
 import org.apache.druid.java.util.common.DateTimes;
 import org.apache.druid.java.util.common.Intervals;
+import org.apache.druid.segment.loading.PartialBaseTableLoadSpec;
+import org.apache.druid.segment.loading.PartialFullSegmentLoadSpec;
 import org.apache.druid.server.coordinator.loading.PartialLoadProfile;
 import org.apache.druid.timeline.DataSegment;
 import org.apache.druid.timeline.SegmentId;
@@ -87,7 +89,7 @@ public class PartialLoadRuleTest
         false,
         tier(1),
         null,
-        exact("nonexistent"),
+        cannotMatch(),
         CannotMatchBehavior.FALL_THROUGH
     );
     DataSegment segment = segmentWithProjections(IN_WINDOW, List.of("a", "b"));
@@ -95,26 +97,45 @@ public class PartialLoadRuleTest
   }
 
   @Test
-  void testAppliesToMatcherDoesNotApplyFullLoadIsDefault()
+  void testAppliesToMatcherDoesNotApplyDefaultStillApplies()
   {
-    // FULL_LOAD is the default. Segment has projections but matcher resolves 
to nothing,
-    // so the rule still applies and the segment gets full-loaded on this tier.
+    // LOAD_ON_DEMAND is the default, and like every behavior other than 
FALL_THROUGH it still applies the rule; a
+    // matcher that cannot reason about the segment does not take the segment 
out of this tier.
     PeriodPartialLoadRule rule = new PeriodPartialLoadRule(
         new Period("P30D"),
         false,
         tier(1),
         null,
-        exact("nonexistent"),
+        cannotMatch(),
         null
     );
     DataSegment segment = segmentWithProjections(IN_WINDOW, List.of("a", "b"));
     Assertions.assertTrue(rule.appliesTo(segment, NOW));
   }
 
+  @Test
+  void testAppliesToProjectionMatcherWithNoMatchingProjection()
+  {
+    // A projection matcher always applies: no matching projection resolves to 
a base-table load, not a non-match, so
+    // onCannotMatch never comes into play. Asserted under FALL_THROUGH 
because that is where the old behavior
+    // (matcher opaque -> rule skipped) would have been visible.
+    PeriodPartialLoadRule rule = new PeriodPartialLoadRule(
+        new Period("P30D"),
+        false,
+        tier(1),
+        null,
+        exact("nonexistent"),
+        CannotMatchBehavior.FALL_THROUGH
+    );
+    DataSegment segment = segmentWithProjections(IN_WINDOW, List.of("a", "b"));
+    Assertions.assertTrue(rule.appliesTo(segment, NOW));
+  }
+
   @Test
   void testAppliesToProjectionAgnosticSegmentFallThrough()
   {
-    // Pre-Druid-32 segment: projections == null. With FALL_THROUGH, rule does 
not apply.
+    // Pre-Druid-32 segment: projections == null. The projection matcher still 
applies, resolving to a base-table
+    // load, so FALL_THROUGH does not skip the rule.
     PeriodPartialLoadRule rule = new PeriodPartialLoadRule(
         new Period("P30D"),
         false,
@@ -124,23 +145,32 @@ public class PartialLoadRuleTest
         CannotMatchBehavior.FALL_THROUGH
     );
     DataSegment segment = segmentWithProjections(IN_WINDOW, null);
-    Assertions.assertFalse(rule.appliesTo(segment, NOW));
+    Assertions.assertTrue(rule.appliesTo(segment, NOW));
   }
 
   @Test
-  void testAppliesToProjectionAgnosticSegmentFullLoad()
+  void testRunProjectionAgnosticSegmentRoutesToBaseTablePartialLoad()
   {
-    // Default behavior: pre-Druid-32 segments fall into full-load on this 
tier.
+    // Pre-Druid-32 segment: projections == null. The projection matcher 
resolves to a base-table load, so run()
+    // dispatches a partial load of the segment's rows rather than falling 
back to whole-segment replication.
     PeriodPartialLoadRule rule = new PeriodPartialLoadRule(
         new Period("P30D"),
         false,
-        tier(1),
+        tier(2),
         null,
         exact("a"),
         null
     );
     DataSegment segment = segmentWithProjections(IN_WINDOW, null);
-    Assertions.assertTrue(rule.appliesTo(segment, NOW));
+    RecordingHandler handler = new RecordingHandler();
+    rule.run(segment, handler);
+    Assertions.assertEquals(0, handler.replicateCalls);
+    Assertions.assertEquals(1, handler.replicatePartialCalls);
+    Assertions.assertEquals(PartialBaseTableLoadSpec.FINGERPRINT, 
handler.lastProfile.fingerprint());
+    Assertions.assertEquals(
+        PartialBaseTableLoadSpec.wireForm(segment.getLoadSpec(), 
PartialBaseTableLoadSpec.FINGERPRINT),
+        handler.lastProfile.wrappedLoadSpec()
+    );
   }
 
   @Test
@@ -163,26 +193,35 @@ public class PartialLoadRuleTest
   @Test
   void testCascadeFallThroughToFullLoad()
   {
-    // Rule 1: partial { "a" } 30 days, explicit FALL_THROUGH when matcher 
cannot match
+    // Rule 1: partial cluster-group load over 30 days, explicit FALL_THROUGH 
when the matcher cannot match
     PartialLoadRule partial = new PeriodPartialLoadRule(
         new Period("P30D"),
         false,
         tier(1),
         null,
-        exact("a"),
+        cannotMatch(),
         CannotMatchBehavior.FALL_THROUGH
     );
-    // Rule 2: forever full load (no projections required)
+    // Rule 2: forever full load
     ForeverLoadRule full = new ForeverLoadRule(tier(1), null);
 
-    // Segment with no projections. Partial rule falls through; cascade lands 
on full.
-    DataSegment legacy = segmentWithProjections(IN_WINDOW, null);
-    Assertions.assertFalse(partial.appliesTo(legacy, NOW));
-    Assertions.assertTrue(full.appliesTo(legacy, NOW));
+    // Non-clustered segment: the cluster-group matcher is opaque, so the 
partial rule falls through and the cascade
+    // lands on full.
+    DataSegment unclustered = segmentWithProjections(IN_WINDOW, null);
+    Assertions.assertFalse(partial.appliesTo(unclustered, NOW));
+    Assertions.assertTrue(full.appliesTo(unclustered, NOW));
 
-    // Segment with matching projection. Partial rule applies, cascade stops 
there.
-    DataSegment modern = segmentWithProjections(IN_WINDOW, List.of("a", "b"));
-    Assertions.assertTrue(partial.appliesTo(modern, NOW));
+    // A projection rule, by contrast, always applies and so always stops the 
cascade.
+    PartialLoadRule projectionPartial = new PeriodPartialLoadRule(
+        new Period("P30D"),
+        false,
+        tier(1),
+        null,
+        exact("a"),
+        CannotMatchBehavior.FALL_THROUGH
+    );
+    Assertions.assertTrue(projectionPartial.appliesTo(unclustered, NOW));
+    
Assertions.assertTrue(projectionPartial.appliesTo(segmentWithProjections(IN_WINDOW,
 List.of("a", "b")), NOW));
   }
 
   @Test
@@ -224,7 +263,7 @@ public class PartialLoadRuleTest
         "matcher": {"type": "exactProjection", "names": ["a"]}\
         }""";
     PeriodPartialLoadRule rule = (PeriodPartialLoadRule) 
OBJECT_MAPPER.readValue(json, Rule.class);
-    Assertions.assertEquals(CannotMatchBehavior.FULL_LOAD, 
rule.getOnCannotMatch());
+    Assertions.assertEquals(CannotMatchBehavior.LOAD_ON_DEMAND, 
rule.getOnCannotMatch());
     Assertions.assertEquals(PeriodLoadRule.DEFAULT_INCLUDE_FUTURE, 
rule.isIncludeFuture());
     Assertions.assertEquals(
         Map.of(DruidServer.DEFAULT_TIER, DruidServer.DEFAULT_NUM_REPLICANTS),
@@ -233,11 +272,11 @@ public class PartialLoadRuleTest
   }
 
   @Test
-  void testUnknownOnCannotMatchValueDeserializesToFullLoadDefault() throws 
Exception
+  void testUnknownOnCannotMatchValueDeserializesToDefault() throws Exception
   {
     // Simulates an older coordinator reading a rule authored by a newer 
version that introduced
     // a new CannotMatchBehavior value. The rule should parse, with the 
unknown value falling
-    // back to the constructor's default (FULL_LOAD) rather than failing 
deserialization.
+    // back to the constructor's default (LOAD_ON_DEMAND) rather than failing 
deserialization.
     String json = """
         {\
         "type": "loadPartialByPeriod",\
@@ -246,7 +285,7 @@ public class PartialLoadRuleTest
         "onCannotMatch": "SOME_FUTURE_BEHAVIOR"\
         }""";
     PeriodPartialLoadRule rule = (PeriodPartialLoadRule) 
OBJECT_MAPPER.readValue(json, Rule.class);
-    Assertions.assertEquals(CannotMatchBehavior.FULL_LOAD, 
rule.getOnCannotMatch());
+    Assertions.assertEquals(CannotMatchBehavior.LOAD_ON_DEMAND, 
rule.getOnCannotMatch());
   }
 
   @Test
@@ -318,17 +357,17 @@ public class PartialLoadRuleTest
   }
 
   @Test
-  void testRunWithFullLoadFallbackRoutesToReplicateSegment()
+  void testRunWithLoadOnDemandFallbackRoutesToReplicateSegment()
   {
-    // Matcher does not apply but the rule's onCannotMatch is FULL_LOAD, so 
run() falls back to the regular full-load
-    // handler.
+    // Matcher does not apply and onCannotMatch is LOAD_ON_DEMAND, so run() 
dispatches the segment with no partial
+    // wrapper at all — on a virtual-storage historical its bundles are then 
fetched as queries touch them.
     PeriodPartialLoadRule rule = new PeriodPartialLoadRule(
         new Period("P30D"),
         false,
         tier(2),
         null,
-        exact("nonexistent"),
-        CannotMatchBehavior.FULL_LOAD
+        cannotMatch(),
+        CannotMatchBehavior.LOAD_ON_DEMAND
     );
     DataSegment segment = segmentWithProjections(IN_WINDOW, List.of("a", "b"));
     RecordingHandler handler = new RecordingHandler();
@@ -339,6 +378,72 @@ public class PartialLoadRuleTest
     Assertions.assertNull(handler.lastProfile);
   }
 
+  @Test
+  void testRunWithBaseLoadFallbackRoutesToBaseTablePartialLoad()
+  {
+    PeriodPartialLoadRule rule = new PeriodPartialLoadRule(
+        new Period("P30D"),
+        false,
+        tier(2),
+        null,
+        cannotMatch(),
+        CannotMatchBehavior.BASE_LOAD
+    );
+    DataSegment segment = segmentWithProjections(IN_WINDOW, List.of("a", "b"));
+    RecordingHandler handler = new RecordingHandler();
+    rule.run(segment, handler);
+    Assertions.assertEquals(0, handler.replicateCalls);
+    Assertions.assertEquals(1, handler.replicatePartialCalls);
+    Assertions.assertEquals(PartialBaseTableLoadSpec.FINGERPRINT, 
handler.lastProfile.fingerprint());
+    Assertions.assertEquals(
+        PartialBaseTableLoadSpec.wireForm(segment.getLoadSpec(), 
PartialBaseTableLoadSpec.FINGERPRINT),
+        handler.lastProfile.wrappedLoadSpec()
+    );
+  }
+
+  @Test
+  void testRunWithFullLoadFallbackRoutesToFullSegmentPartialLoad()
+  {
+    // FULL_LOAD means every bundle resident, which needs the partial-load 
path: dispatching without a wrapper would
+    // only make the segment available on demand.
+    PeriodPartialLoadRule rule = new PeriodPartialLoadRule(
+        new Period("P30D"),
+        false,
+        tier(2),
+        null,
+        cannotMatch(),
+        CannotMatchBehavior.FULL_LOAD
+    );
+    DataSegment segment = segmentWithProjections(IN_WINDOW, List.of("a", "b"));
+    RecordingHandler handler = new RecordingHandler();
+    rule.run(segment, handler);
+    Assertions.assertEquals(0, handler.replicateCalls);
+    Assertions.assertEquals(1, handler.replicatePartialCalls);
+    Assertions.assertEquals(PartialFullSegmentLoadSpec.FINGERPRINT, 
handler.lastProfile.fingerprint());
+    Assertions.assertEquals(
+        PartialFullSegmentLoadSpec.wireForm(segment.getLoadSpec(), 
PartialFullSegmentLoadSpec.FINGERPRINT),
+        handler.lastProfile.wrappedLoadSpec()
+    );
+  }
+
+  @Test
+  void testBaseLoadAndFullLoadFingerprintsDiffer()
+  {
+    // Otherwise the coordinator could not tell a rule swap between the two 
apart, and would never re-issue the load.
+    Assertions.assertNotEquals(PartialBaseTableLoadSpec.FINGERPRINT, 
PartialFullSegmentLoadSpec.FINGERPRINT);
+  }
+
+  @Test
+  void testAllCannotMatchBehaviorsRoundTrip() throws Exception
+  {
+    for (CannotMatchBehavior behavior : CannotMatchBehavior.values()) {
+      ForeverPartialLoadRule rule = new ForeverPartialLoadRule(tier(1), null, 
exact("a"), behavior);
+      Rule reread = 
OBJECT_MAPPER.readValue(OBJECT_MAPPER.writeValueAsString(rule), Rule.class);
+      Assertions.assertEquals(rule, reread, "round trip failed for " + 
behavior);
+      Assertions.assertEquals(behavior, ((ForeverPartialLoadRule) 
reread).getOnCannotMatch());
+    }
+  }
+
   @Test
   void testPeriodEquals()
   {
@@ -384,6 +489,16 @@ public class PartialLoadRuleTest
     return new ExactProjectionPartialLoadMatcher(Arrays.asList(names));
   }
 
+  /**
+   * A matcher that cannot match any segment this test builds, so the rule's 
{@link CannotMatchBehavior} decides.
+   * Projection matchers no longer serve this purpose: they always apply, 
falling back to a base-table load when none
+   * of their projections are present. A cluster-group matcher is opaque to 
the non-clustered segments here.
+   */
+  private static PartialLoadMatcher cannotMatch()
+  {
+    return new WildcardClusterGroupPartialLoadMatcher(List.of(Map.of("tenant", 
"acme")), null);
+  }
+
   private static Map<String, Integer> tier(int n)
   {
     return Map.of(DruidServer.DEFAULT_TIER, n);
diff --git 
a/server/src/test/java/org/apache/druid/server/coordinator/rules/WildcardProjectionPartialLoadMatcherTest.java
 
b/server/src/test/java/org/apache/druid/server/coordinator/rules/WildcardProjectionPartialLoadMatcherTest.java
index 0f1a12e85e8..b8d26801641 100644
--- 
a/server/src/test/java/org/apache/druid/server/coordinator/rules/WildcardProjectionPartialLoadMatcherTest.java
+++ 
b/server/src/test/java/org/apache/druid/server/coordinator/rules/WildcardProjectionPartialLoadMatcherTest.java
@@ -26,6 +26,7 @@ import org.apache.druid.error.DruidExceptionMatcher;
 import org.apache.druid.jackson.DefaultObjectMapper;
 import org.apache.druid.java.util.common.DateTimes;
 import org.apache.druid.java.util.common.Intervals;
+import org.apache.druid.segment.loading.PartialBaseTableLoadSpec;
 import org.apache.druid.timeline.DataSegment;
 import org.apache.druid.timeline.SegmentId;
 import org.apache.druid.timeline.partition.NumberedShardSpec;
@@ -187,13 +188,13 @@ public class WildcardProjectionPartialLoadMatcherTest
   void testMatchReturnsNullWhenNoPatternsHit()
   {
     // Segment has projections but none match any configured pattern: distinct 
code path from the
-    // projection-agnostic-segment short-circuit.
+    // projection-agnostic-segment short-circuit. Both resolve to a base-table 
load.
     WildcardProjectionPartialLoadMatcher matcher = new 
WildcardProjectionPartialLoadMatcher(
         List.of("user_*"),
         null
     );
     DataSegment segment = segmentWithProjections(List.of("session_daily", 
"session_hourly", "other"));
-    Assertions.assertNull(matcher.match(segment, segment.getLoadSpec()));
+    assertBaseTableLoad(matcher.match(segment, segment.getLoadSpec()), 
segment.getLoadSpec());
   }
 
   @Test
@@ -215,14 +216,17 @@ public class WildcardProjectionPartialLoadMatcherTest
   }
 
   @Test
-  void testReturnsNullForProjectionAgnosticSegment()
+  void testFallsBackToBaseTableForProjectionAgnosticSegment()
   {
     WildcardProjectionPartialLoadMatcher matcher = new 
WildcardProjectionPartialLoadMatcher(
         List.of("*"),
         null
     );
-    Assertions.assertNull(matcher.match(segmentWithProjections(null), 
BASE_LOAD_SPEC));
-    
Assertions.assertNull(matcher.match(segmentWithProjections(Collections.emptyList()),
 BASE_LOAD_SPEC));
+    assertBaseTableLoad(matcher.match(segmentWithProjections(null), 
BASE_LOAD_SPEC), BASE_LOAD_SPEC);
+    assertBaseTableLoad(
+        matcher.match(segmentWithProjections(Collections.emptyList()), 
BASE_LOAD_SPEC),
+        BASE_LOAD_SPEC
+    );
   }
 
   @Test
@@ -396,15 +400,26 @@ public class WildcardProjectionPartialLoadMatcherTest
   }
 
   @Test
-  void testExcludeAllMatchedReturnsNull()
+  void testExcludeAllMatchedFallsBackToBaseTable()
   {
-    // If excludePatterns consume every match the result is empty; the matcher 
reports "does not match".
+    // If excludePatterns consume every match the result is empty, which 
resolves to a base-table load — the same
+    // outcome as a segment that never had the projections at all.
     WildcardProjectionPartialLoadMatcher matcher = new 
WildcardProjectionPartialLoadMatcher(
         List.of("user_*"),
         List.of("user_*")
     );
     DataSegment segment = segmentWithProjections(List.of("user_daily", 
"user_hourly"));
-    Assertions.assertNull(matcher.match(segment, segment.getLoadSpec()));
+    assertBaseTableLoad(matcher.match(segment, segment.getLoadSpec()), 
segment.getLoadSpec());
+  }
+
+  private static void assertBaseTableLoad(PartialLoadMatcher.MatchResult 
result, Map<String, Object> baseLoadSpec)
+  {
+    Assertions.assertNotNull(result);
+    Assertions.assertEquals(PartialBaseTableLoadSpec.FINGERPRINT, 
result.fingerprint());
+    Assertions.assertEquals(
+        PartialBaseTableLoadSpec.wireForm(baseLoadSpec, 
PartialBaseTableLoadSpec.FINGERPRINT),
+        result.wrappedLoadSpec()
+    );
   }
 
   @Test
diff --git 
a/server/src/test/java/org/apache/druid/server/http/DataSourcesResourceTest.java
 
b/server/src/test/java/org/apache/druid/server/http/DataSourcesResourceTest.java
index 4f57d6cb31f..79c07484e45 100644
--- 
a/server/src/test/java/org/apache/druid/server/http/DataSourcesResourceTest.java
+++ 
b/server/src/test/java/org/apache/druid/server/http/DataSourcesResourceTest.java
@@ -60,6 +60,7 @@ import 
org.apache.druid.server.coordinator.rules.IntervalDropRule;
 import org.apache.druid.server.coordinator.rules.IntervalLoadRule;
 import org.apache.druid.server.coordinator.rules.IntervalPartialLoadRule;
 import org.apache.druid.server.coordinator.rules.Rule;
+import 
org.apache.druid.server.coordinator.rules.WildcardClusterGroupPartialLoadMatcher;
 import org.apache.druid.server.security.Access;
 import org.apache.druid.server.security.Action;
 import org.apache.druid.server.security.AuthConfig;
@@ -784,7 +785,7 @@ public class DataSourcesResourceTest
         null,
         null,
         new ExactProjectionPartialLoadMatcher(ImmutableList.of("user_daily")),
-        CannotMatchBehavior.FULL_LOAD
+        CannotMatchBehavior.LOAD_ON_DEMAND
     );
     DataSourcesResource dataSourcesResource =
         new DataSourcesResource(
@@ -822,15 +823,16 @@ public class DataSourcesResourceTest
   @Test
   public void testIsHandOffCompleteWithPartialLoadRuleFallThrough()
   {
-    // A FALL_THROUGH partial rule whose matcher does not resolve on the 
segment (the projection it asks for is not
-    // present) should not halt the cascade. The next rule (drop) catches the 
segment, so the response is true.
+    // A FALL_THROUGH partial rule whose matcher does not resolve on the 
segment should not halt the cascade. The next
+    // rule (drop) catches the segment, so the response is true. The matcher 
is a cluster-group one because the
+    // segment is not clustered: projection matchers always apply, falling 
back to a base-table load.
     MetadataRuleManager databaseRuleManager = 
EasyMock.createMock(MetadataRuleManager.class);
     Interval ruleInterval = 
Intervals.of("2013-01-01T00:00:00Z/2013-01-03T00:00:00Z");
     Rule partialRule = new IntervalPartialLoadRule(
         ruleInterval,
         null,
         null,
-        new ExactProjectionPartialLoadMatcher(ImmutableList.of("user_daily")),
+        new 
WildcardClusterGroupPartialLoadMatcher(ImmutableList.of(ImmutableMap.of("tenant",
 "acme")), null),
         CannotMatchBehavior.FALL_THROUGH
     );
     Rule dropRule = new IntervalDropRule(ruleInterval);


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

Reply via email to