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 c612a1e82d5 feat: composite partial load matcher and load spec (#19791)
c612a1e82d5 is described below

commit c612a1e82d5ea351a538432292a763accd171ab5
Author: Clint Wylie <[email protected]>
AuthorDate: Thu Jul 30 22:02:10 2026 -0700

    feat: composite partial load matcher and load spec (#19791)
---
 .../segment/loading/CompositePartialLoadSpec.java  | 217 ++++++++
 .../loading/PartialClusterGroupLoadSpec.java       |   6 +-
 .../druid/segment/loading/PartialLoadSpec.java     |  41 +-
 .../segment/loading/PartialProjectionLoadSpec.java |   6 +-
 .../loading/CompositePartialLoadSpecTest.java      | 612 +++++++++++++++++++++
 .../apache/druid/guice/PartialLoadSpecModule.java  |  10 +-
 .../rules/CompositePartialLoadMatcher.java         | 198 +++++++
 .../coordinator/rules/PartialLoadMatcher.java      |   3 +-
 ...egmentLocalCacheManagerPartialRuleLoadTest.java |  94 ++++
 .../rules/CompositePartialLoadMatcherTest.java     | 457 +++++++++++++++
 ...WildcardClusterGroupPartialLoadMatcherTest.java |  10 +
 11 files changed, 1630 insertions(+), 24 deletions(-)

diff --git 
a/processing/src/main/java/org/apache/druid/segment/loading/CompositePartialLoadSpec.java
 
b/processing/src/main/java/org/apache/druid/segment/loading/CompositePartialLoadSpec.java
new file mode 100644
index 00000000000..fcc81dd21b6
--- /dev/null
+++ 
b/processing/src/main/java/org/apache/druid/segment/loading/CompositePartialLoadSpec.java
@@ -0,0 +1,217 @@
+/*
+ * 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 com.google.common.base.Preconditions;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.segment.file.SegmentFileMetadata;
+import org.apache.druid.timeline.DataSegment;
+import org.apache.druid.utils.CollectionUtils;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * A {@link PartialLoadSpec} that combines several sibling partial-load specs, 
selecting the union of every member's
+ * bundles. This lets one partial-load rule mix schemes and lets a single 
scheme express selections a single
+ * matcher cannot, such as a disjoint union of include/exclude pattern pairs 
in wildcard based matchers.
+ * <p>
+ * Members are held as raw {@link Map} load specs and materialized lazily, the 
same way the base class handles
+ * {@link #getDelegate()}. Member load specs deliberately omit the {@link 
#DELEGATE_FIELD} field: every member of a
+ * composite describes a selection within the <em>same</em> segment, so 
repeating the backend load spec once per
+ * member would bloat every load request and announcement for no benefit. This 
spec injects its own
+ * {@link #getDelegate()} into each member as it materializes it, which is why 
a member carrying its own
+ * {@code delegate} is rejected at construction (it means the producing 
matcher failed to strip it, and silently
+ * overwriting it could mask a real mismatch).
+ * <p>
+ * Composites nest: a member that is itself a {@code partialComposite} 
receives the injected delegate and injects it
+ * into its own members in turn.
+ */
+@JsonTypeName(CompositePartialLoadSpec.TYPE)
+public class CompositePartialLoadSpec extends PartialLoadSpec
+{
+  public static final String TYPE = "partialComposite";
+
+  /**
+   * Builds the raw {@link Map} form of a {@link CompositePartialLoadSpec} 
request. Used by the coordinator-side
+   * matcher, which doesn't instantiate the typed class because doing so would 
require plumbing an
+   * {@link ObjectMapper} through every matcher just to satisfy the 
constructor's lazy-materialization suppliers.
+   * <p>
+   * Each entry of {@code members} must be the raw {@link Map} form of some 
other {@link PartialLoadSpec}
+   * <em>without</em> its {@link #DELEGATE_FIELD} field; see the class doc.
+   */
+  public static Map<String, Object> wireForm(
+      Map<String, Object> delegate,
+      List<Map<String, Object>> members,
+      String fingerprint
+  )
+  {
+    return Map.of(
+        TYPE_FIELD, TYPE,
+        DELEGATE_FIELD, delegate,
+        "members", members,
+        FINGERPRINT_FIELD, fingerprint
+    );
+  }
+
+  private final List<Map<String, Object>> members;
+  private final Supplier<List<PartialLoadSpec>> materializedMembersSupplier;
+
+  @JsonCreator
+  public CompositePartialLoadSpec(
+      @JsonProperty("delegate") Map<String, Object> delegate,
+      @JsonProperty("members") List<Map<String, Object>> members,
+      @JsonProperty("fingerprint") String fingerprint,
+      @JacksonInject ObjectMapper jsonMapper
+  )
+  {
+    super(delegate, fingerprint, jsonMapper);
+    Preconditions.checkArgument(
+        !CollectionUtils.isNullOrEmpty(members),
+        "members must not be null or empty"
+    );
+    final List<Map<String, Object>> copied = new ArrayList<>(members.size());
+    for (int i = 0; i < members.size(); i++) {
+      copied.add(validateMember(members.get(i), i));
+    }
+    this.members = List.copyOf(copied);
+    this.materializedMembersSupplier = Suppliers.memoize(() -> 
materializeMembers(jsonMapper));
+  }
+
+  @JsonProperty
+  public List<Map<String, Object>> getMembers()
+  {
+    return members;
+  }
+
+  /**
+   * The union of every member's selected bundles, in member order and then 
member-internal order. Duplicates are
+   * dropped: two members of the same scheme can legitimately select 
overlapping bundles (e.g. two cluster-group
+   * selections that share a group), and the caller treats the result as a set.
+   * <p>
+   * The base bundle needs no special handling here —
+   * {@code PartialSegmentMetadataCacheEntry#bundlesInMountOrder} expands each 
selected bundle's inferred
+   * dependencies, which pins {@code __base} exactly once regardless of how 
many members asked for something that
+   * depends on it.
+   * <p>
+   * Returns an empty list only when every member selects nothing (the 
"sibling-empty" case propagated through
+   * composition).
+   */
+  @Override
+  public List<String> getSelectedBundleNames(DataSegment segment, 
SegmentFileMetadata metadata)
+  {
+    final LinkedHashSet<String> union = new LinkedHashSet<>();
+    for (PartialLoadSpec member : materializedMembersSupplier.get()) {
+      union.addAll(member.getSelectedBundleNames(segment, metadata));
+    }
+    return List.copyOf(union);
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    CompositePartialLoadSpec that = (CompositePartialLoadSpec) o;
+    return Objects.equals(getDelegate(), that.getDelegate())
+        && Objects.equals(members, that.members)
+        && Objects.equals(getFingerprint(), that.getFingerprint());
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Objects.hash(getDelegate(), members, getFingerprint());
+  }
+
+  @Override
+  public String toString()
+  {
+    return "CompositePartialLoadSpec{" +
+           "delegate=" + getDelegate() +
+           ", members=" + members +
+           ", fingerprint=" + getFingerprint() +
+           '}';
+  }
+
+  private List<PartialLoadSpec> materializeMembers(ObjectMapper jsonMapper)
+  {
+    final List<PartialLoadSpec> materialized = new ArrayList<>(members.size());
+    for (Map<String, Object> member : members) {
+      // Splice this composite's delegate into the member before materializing 
it: members omit it on the wire, but
+      // the PartialLoadSpec constructor requires one.
+      final Map<String, Object> withDelegate = new LinkedHashMap<>(member);
+      withDelegate.put(DELEGATE_FIELD, getDelegate());
+      final LoadSpec memberSpec = jsonMapper.convertValue(withDelegate, 
LoadSpec.class);
+      if (!(memberSpec instanceof PartialLoadSpec partialMember)) {
+        throw DruidException.defensive(
+            "Composite partial load spec member of type[%s] materialized to 
non-partial type[%s]",
+            member.get(TYPE_FIELD),
+            memberSpec.getClass().getSimpleName()
+        );
+      }
+      materialized.add(partialMember);
+    }
+    return materialized;
+  }
+
+  /**
+   * Validates and defensively copies one member load spec. A member must 
carry a partial-load {@link #TYPE_FIELD} and
+   * must not carry a {@link #DELEGATE_FIELD} of its own; see the class doc 
for why.
+   */
+  private static Map<String, Object> validateMember(Map<String, Object> 
member, int index)
+  {
+    if (member == null || member.isEmpty()) {
+      throw DruidException.defensive("members[%s] must not be null or empty", 
index);
+    }
+    if (!hasPartialTypePrefix(member)) {
+      throw DruidException.defensive(
+          "members[%s] must be a partial load spec with a type starting with 
[%s], got type[%s]",
+          index,
+          TYPE_PREFIX,
+          member.get(TYPE_FIELD)
+      );
+    }
+    if (member.containsKey(DELEGATE_FIELD)) {
+      throw DruidException.defensive(
+          "members[%s] of type[%s] must not carry its own [%s]; the composite 
supplies it",
+          index,
+          member.get(TYPE_FIELD),
+          DELEGATE_FIELD
+      );
+    }
+    return Map.copyOf(member);
+  }
+}
diff --git 
a/processing/src/main/java/org/apache/druid/segment/loading/PartialClusterGroupLoadSpec.java
 
b/processing/src/main/java/org/apache/druid/segment/loading/PartialClusterGroupLoadSpec.java
index cbc27ad3cf9..2764a100f5c 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/loading/PartialClusterGroupLoadSpec.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/loading/PartialClusterGroupLoadSpec.java
@@ -62,10 +62,10 @@ public class PartialClusterGroupLoadSpec extends 
PartialLoadSpec
   )
   {
     return Map.of(
-        "type", TYPE,
-        "delegate", delegate,
+        TYPE_FIELD, TYPE,
+        DELEGATE_FIELD, delegate,
         "clusterGroupIndices", clusterGroupIndices,
-        "fingerprint", fingerprint
+        FINGERPRINT_FIELD, fingerprint
     );
   }
 
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 c9b0e82acdd..0e3174647c3 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
@@ -64,32 +64,47 @@ public abstract class PartialLoadSpec implements LoadSpec
    */
   public static final String TYPE_PREFIX = "partial";
 
+  /**
+   * Wire field carrying the Jackson type discriminator. Named here (rather 
than only inline) because code that
+   * inspects or rewrites raw {@link Map}-form load specs needs to agree with 
the {@code @JsonTypeInfo} property name
+   * on {@link LoadSpec}.
+   */
+  public static final String TYPE_FIELD = "type";
+
+  /**
+   * Wire field carrying the raw inner load spec, provided by {@link 
#getDelegate()}.
+   */
+  public static final String DELEGATE_FIELD = "delegate";
+
+  /**
+   * Wire field carrying the partial-load request fingerprint, provided by 
{@link #getFingerprint()}.
+   */
+  public static final String FINGERPRINT_FIELD = "fingerprint";
+
   /**
    * Returns {@code true} if {@code loadSpec} matches the shape of the {@link 
PartialLoadSpec} subtype.
-   * Convention-based detection (no subtype allowlist): the {@code type} field 
must be a {@link String} starting with
-   * {@link #TYPE_PREFIX}, the {@code fingerprint} field must be a {@link 
String}, and the {@code delegate} field
-   * must be a {@link Map}. These properties are enforced by this base class's 
{@code @JsonProperty} getters, so any
-   * subtype satisfies them automatically.
+   * Convention-based detection (no subtype allowlist): the {@link 
#TYPE_FIELD} field must be a {@link String}
+   * starting with {@link #TYPE_PREFIX}, the {@link #FINGERPRINT_FIELD} field 
must be a {@link String}, and the
+   * {@link #DELEGATE_FIELD} field must be a {@link Map}. These properties are 
enforced by this base class's
+   * {@code @JsonProperty} getters, so any subtype satisfies them 
automatically.
    */
   public static boolean detectPartialLoadSpec(@Nullable Map<String, Object> 
loadSpec)
   {
-    return loadSpec != null
-           && loadSpec.get("type") instanceof String typeString
-           && typeString.startsWith(TYPE_PREFIX)
-           && loadSpec.get("fingerprint") instanceof String
-           && loadSpec.get("delegate") instanceof Map;
+    return hasPartialTypePrefix(loadSpec)
+           && loadSpec.get(FINGERPRINT_FIELD) instanceof String
+           && loadSpec.get(DELEGATE_FIELD) instanceof Map;
   }
 
   /**
-   * Returns {@code true} if {@code loadSpec}'s {@code type} field claims 
partial-load semantics (starts with
+   * Returns {@code true} if {@code loadSpec}'s {@link #TYPE_FIELD} field 
claims partial-load semantics (starts with
    * {@link #TYPE_PREFIX}), regardless of whether the remaining wire form is 
well-formed. Useful when callers want
-   * to distinguish "not a partial-load wrapper" from "claims to be partial 
but the {@code fingerprint} or
-   * {@code delegate} fields are missing or malformed" — the latter typically 
indicates a bug worth logging.
+   * to distinguish "not a partial-load wrapper" from "claims to be partial 
but the {@link #FINGERPRINT_FIELD} or
+   * {@link #DELEGATE_FIELD} fields are missing or malformed" — the latter 
typically indicates a bug worth logging.
    */
   public static boolean hasPartialTypePrefix(@Nullable Map<String, Object> 
loadSpec)
   {
     return loadSpec != null
-           && loadSpec.get("type") instanceof String typeString
+           && loadSpec.get(TYPE_FIELD) instanceof String typeString
            && typeString.startsWith(TYPE_PREFIX);
   }
 
diff --git 
a/processing/src/main/java/org/apache/druid/segment/loading/PartialProjectionLoadSpec.java
 
b/processing/src/main/java/org/apache/druid/segment/loading/PartialProjectionLoadSpec.java
index c063f33db8e..61bc550102d 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/loading/PartialProjectionLoadSpec.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/loading/PartialProjectionLoadSpec.java
@@ -59,10 +59,10 @@ public class PartialProjectionLoadSpec extends 
PartialLoadSpec
   )
   {
     return Map.of(
-        "type", TYPE,
-        "delegate", delegate,
+        TYPE_FIELD, TYPE,
+        DELEGATE_FIELD, delegate,
         "projections", projections,
-        "fingerprint", fingerprint
+        FINGERPRINT_FIELD, fingerprint
     );
   }
 
diff --git 
a/processing/src/test/java/org/apache/druid/segment/loading/CompositePartialLoadSpecTest.java
 
b/processing/src/test/java/org/apache/druid/segment/loading/CompositePartialLoadSpecTest.java
new file mode 100644
index 00000000000..7dbb6b158de
--- /dev/null
+++ 
b/processing/src/test/java/org/apache/druid/segment/loading/CompositePartialLoadSpecTest.java
@@ -0,0 +1,612 @@
+/*
+ * 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.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.ClusterGroupTuples;
+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.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+class CompositePartialLoadSpecTest
+{
+  private static final Map<String, Object> DELEGATE = ImmutableMap.of(
+      "type", "stub",
+      "path", "/var/druid/segments/foo"
+  );
+  private static final String FINGERPRINT = "v1:abcdef0123456789";
+
+  /**
+   * A {@code partialProjection} member load spec, i.e. {@link 
PartialProjectionLoadSpec#wireForm} minus its
+   * {@code delegate} — which is what {@code CompositePartialLoadMatcher} 
emits and what the composite injects into.
+   */
+  private static Map<String, Object> projectionMember(List<String> 
projections, String fingerprint)
+  {
+    return member(PartialProjectionLoadSpec.wireForm(DELEGATE, projections, 
fingerprint));
+  }
+
+  private static Map<String, Object> clusterGroupMember(List<Integer> indices, 
String fingerprint)
+  {
+    return member(PartialClusterGroupLoadSpec.wireForm(DELEGATE, indices, 
fingerprint));
+  }
+
+  private static Map<String, Object> member(Map<String, Object> wireForm)
+  {
+    final Map<String, Object> stripped = new HashMap<>(wireForm);
+    stripped.remove(PartialLoadSpec.DELEGATE_FIELD);
+    return stripped;
+  }
+
+  private static ObjectMapper configuredMapper()
+  {
+    final ObjectMapper m = new DefaultObjectMapper();
+    final SimpleModule module = new SimpleModule();
+    module.registerSubtypes(
+        CompositePartialLoadSpec.class,
+        PartialProjectionLoadSpec.class,
+        PartialClusterGroupLoadSpec.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
+  {
+    CompositePartialLoadSpec spec = new CompositePartialLoadSpec(
+        DELEGATE,
+        List.of(
+            projectionMember(List.of("user_hourly"), "v1:aaaaaaaaaaaaaaaa"),
+            clusterGroupMember(List.of(0, 2), "v1:bbbbbbbbbbbbbbbb")
+        ),
+        FINGERPRINT,
+        jsonMapper
+    );
+    String json = jsonMapper.writeValueAsString(spec);
+    LoadSpec reread = jsonMapper.readValue(json, LoadSpec.class);
+    Assertions.assertInstanceOf(CompositePartialLoadSpec.class, reread);
+    Assertions.assertEquals(spec, reread);
+  }
+
+  @Test
+  void testWireFormHasPartialCompositeType() throws Exception
+  {
+    CompositePartialLoadSpec spec = new CompositePartialLoadSpec(
+        DELEGATE,
+        List.of(
+            projectionMember(List.of("user_hourly"), "v1:aaaaaaaaaaaaaaaa"),
+            clusterGroupMember(List.of(0), "v1:bbbbbbbbbbbbbbbb")
+        ),
+        FINGERPRINT,
+        jsonMapper
+    );
+    Map<String, Object> wireForm = jsonMapper.readValue(
+        jsonMapper.writeValueAsString(spec),
+        new TypeReference<>()
+        {
+        }
+    );
+    Assertions.assertEquals("partialComposite", wireForm.get("type"));
+    Assertions.assertEquals(DELEGATE, wireForm.get("delegate"));
+    Assertions.assertEquals(FINGERPRINT, wireForm.get("fingerprint"));
+    Assertions.assertEquals(
+        List.of(
+            projectionMember(List.of("user_hourly"), "v1:aaaaaaaaaaaaaaaa"),
+            clusterGroupMember(List.of(0), "v1:bbbbbbbbbbbbbbbb")
+        ),
+        wireForm.get("members")
+    );
+  }
+
+  @Test
+  void testMembersOnWireCarryNoDelegate() throws Exception
+  {
+    // The composite carries the backend load spec exactly once, at the top 
level.
+    CompositePartialLoadSpec spec = new CompositePartialLoadSpec(
+        DELEGATE,
+        List.of(
+            projectionMember(List.of("user_hourly"), "v1:aaaaaaaaaaaaaaaa"),
+            clusterGroupMember(List.of(0), "v1:bbbbbbbbbbbbbbbb")
+        ),
+        FINGERPRINT,
+        jsonMapper
+    );
+    Map<String, Object> wireForm = jsonMapper.readValue(
+        jsonMapper.writeValueAsString(spec),
+        new TypeReference<>()
+        {
+        }
+    );
+    @SuppressWarnings("unchecked")
+    final List<Map<String, Object>> members = (List<Map<String, Object>>) 
wireForm.get("members");
+    for (Map<String, Object> m : members) {
+      Assertions.assertFalse(m.containsKey("delegate"), "member should not 
carry a delegate: " + m);
+    }
+  }
+
+  @Test
+  void testDelegateIsInjectedIntoMembersOnMaterialization()
+  {
+    // Members omit the delegate on the wire, so the only way a member's own 
loadSegment can work is if the composite
+    // spliced its delegate in. Materialization happens lazily inside 
getSelectedBundleNames.
+    final SegmentFileMetadata metadata = 
projectionMetadata(List.of("user_hourly"));
+    CompositePartialLoadSpec spec = new CompositePartialLoadSpec(
+        DELEGATE,
+        List.of(
+            projectionMember(List.of("user_hourly"), "v1:aaaaaaaaaaaaaaaa"),
+            projectionMember(List.of("user_hourly"), "v1:cccccccccccccccc")
+        ),
+        FINGERPRINT,
+        jsonMapper
+    );
+    Assertions.assertEquals(
+        List.of("user_hourly"),
+        spec.getSelectedBundleNames(unclusteredSegment(), metadata)
+    );
+  }
+
+  @Test
+  void testGetSelectedBundleNamesUnionsAcrossSchemes()
+  {
+    final SegmentFileMetadata metadata = clusteredMetadata(
+        List.of(
+            new TableClusterGroupSpec(List.of(0), 10),
+            new TableClusterGroupSpec(List.of(1), 20),
+            new TableClusterGroupSpec(List.of(2), 30)
+        ),
+        List.of("user_hourly")
+    );
+    final DataSegment segment = clusteredSegment(
+        List.of(List.of("acme"), List.of("globex"), List.of("initech"))
+    );
+    CompositePartialLoadSpec spec = new CompositePartialLoadSpec(
+        DELEGATE,
+        List.of(
+            projectionMember(List.of("user_hourly"), "v1:aaaaaaaaaaaaaaaa"),
+            clusterGroupMember(List.of(0, 2), "v1:bbbbbbbbbbbbbbbb")
+        ),
+        FINGERPRINT,
+        jsonMapper
+    );
+    Assertions.assertEquals(
+        List.of(
+            "user_hourly",
+            Projections.getClusterGroupBundleName(List.of(0)),
+            Projections.getClusterGroupBundleName(List.of(2))
+        ),
+        spec.getSelectedBundleNames(segment, metadata)
+    );
+  }
+
+  @Test
+  void testGetSelectedBundleNamesDedupesOverlappingMembers()
+  {
+    // Two same-scheme members whose selections overlap: the union drops the 
duplicate but keeps first-seen order.
+    final SegmentFileMetadata metadata = clusteredMetadata(
+        List.of(
+            new TableClusterGroupSpec(List.of(0), 10),
+            new TableClusterGroupSpec(List.of(1), 20),
+            new TableClusterGroupSpec(List.of(2), 30)
+        ),
+        null
+    );
+    final DataSegment segment = clusteredSegment(
+        List.of(List.of("acme"), List.of("globex"), List.of("initech"))
+    );
+    CompositePartialLoadSpec spec = new CompositePartialLoadSpec(
+        DELEGATE,
+        List.of(
+            clusterGroupMember(List.of(0, 1), "v1:aaaaaaaaaaaaaaaa"),
+            clusterGroupMember(List.of(1, 2), "v1:bbbbbbbbbbbbbbbb")
+        ),
+        FINGERPRINT,
+        jsonMapper
+    );
+    Assertions.assertEquals(
+        List.of(
+            Projections.getClusterGroupBundleName(List.of(0)),
+            Projections.getClusterGroupBundleName(List.of(1)),
+            Projections.getClusterGroupBundleName(List.of(2))
+        ),
+        spec.getSelectedBundleNames(segment, metadata)
+    );
+  }
+
+  @Test
+  void testGetSelectedBundleNamesAllEmptyMembersReturnsEmpty()
+  {
+    // Sibling-empty propagated through composition: every member selected 
nothing.
+    final SegmentFileMetadata metadata = clusteredMetadata(
+        List.of(new TableClusterGroupSpec(List.of(0), 1)),
+        null
+    );
+    final DataSegment segment = clusteredSegment(List.of(List.of("acme")));
+    CompositePartialLoadSpec spec = new CompositePartialLoadSpec(
+        DELEGATE,
+        List.of(
+            clusterGroupMember(List.of(), "v1:partial-empty"),
+            clusterGroupMember(List.of(), "v1:partial-empty")
+        ),
+        FINGERPRINT,
+        jsonMapper
+    );
+    Assertions.assertEquals(List.of(), spec.getSelectedBundleNames(segment, 
metadata));
+  }
+
+  @Test
+  void testNestedCompositeInjectsDelegateRecursively()
+  {
+    final SegmentFileMetadata metadata = clusteredMetadata(
+        List.of(
+            new TableClusterGroupSpec(List.of(0), 10),
+            new TableClusterGroupSpec(List.of(1), 20)
+        ),
+        List.of("user_hourly")
+    );
+    final DataSegment segment = clusteredSegment(List.of(List.of("acme"), 
List.of("globex")));
+    final Map<String, Object> nested = member(
+        CompositePartialLoadSpec.wireForm(
+            DELEGATE,
+            List.of(
+                clusterGroupMember(List.of(0), "v1:aaaaaaaaaaaaaaaa"),
+                clusterGroupMember(List.of(1), "v1:bbbbbbbbbbbbbbbb")
+            ),
+            "v1:dddddddddddddddd"
+        )
+    );
+    CompositePartialLoadSpec spec = new CompositePartialLoadSpec(
+        DELEGATE,
+        List.of(projectionMember(List.of("user_hourly"), 
"v1:cccccccccccccccc"), nested),
+        FINGERPRINT,
+        jsonMapper
+    );
+    Assertions.assertEquals(
+        List.of(
+            "user_hourly",
+            Projections.getClusterGroupBundleName(List.of(0)),
+            Projections.getClusterGroupBundleName(List.of(1))
+        ),
+        spec.getSelectedBundleNames(segment, metadata)
+    );
+  }
+
+  @Test
+  void testMemberDefectPropagates()
+  {
+    // A member's own defensive tripwire is not swallowed by the union.
+    final SegmentFileMetadata metadata = 
projectionMetadata(List.of("user_hourly"));
+    CompositePartialLoadSpec spec = new CompositePartialLoadSpec(
+        DELEGATE,
+        List.of(
+            projectionMember(List.of("user_hourly"), "v1:aaaaaaaaaaaaaaaa"),
+            projectionMember(List.of("nonexistent"), "v1:bbbbbbbbbbbbbbbb")
+        ),
+        FINGERPRINT,
+        jsonMapper
+    );
+    final DruidException thrown = Assertions.assertThrows(
+        DruidException.class,
+        () -> spec.getSelectedBundleNames(unclusteredSegment(), metadata)
+    );
+    Assertions.assertTrue(
+        thrown.getMessage().contains("does not contain 
projection[nonexistent]"),
+        "unexpected message: " + thrown.getMessage()
+    );
+  }
+
+  @Test
+  void testLoadSegmentDelegatesToInner() throws Exception
+  {
+    CompositePartialLoadSpec spec = new CompositePartialLoadSpec(
+        DELEGATE,
+        List.of(projectionMember(List.of("user_hourly"), 
"v1:aaaaaaaaaaaaaaaa")),
+        FINGERPRINT,
+        jsonMapper
+    );
+    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
+  {
+    CompositePartialLoadSpec spec = new CompositePartialLoadSpec(
+        DELEGATE,
+        List.of(projectionMember(List.of("user_hourly"), 
"v1:aaaaaaaaaaaaaaaa")),
+        FINGERPRINT,
+        jsonMapper
+    );
+    StubLoadSpec.RANGE_CALLS.set(0);
+    SegmentRangeReader reader = spec.openRangeReader();
+    Assertions.assertNotNull(reader);
+    Assertions.assertEquals(1, StubLoadSpec.RANGE_CALLS.get());
+  }
+
+  @Test
+  void testOpenRangeReaderReturnsNullWhenInnerDoesNotSupport() throws Exception
+  {
+    CompositePartialLoadSpec spec = new CompositePartialLoadSpec(
+        ImmutableMap.of("type", "stub", "path", "/", "supportsRange", false),
+        List.of(projectionMember(List.of("user_hourly"), 
"v1:aaaaaaaaaaaaaaaa")),
+        FINGERPRINT,
+        jsonMapper
+    );
+    Assertions.assertNull(spec.openRangeReader());
+  }
+
+  @Test
+  void testRejectsNullDelegate()
+  {
+    Assertions.assertThrows(
+        NullPointerException.class,
+        () -> new CompositePartialLoadSpec(
+            null,
+            List.of(projectionMember(List.of("a"), "v1:x")),
+            "v1:x",
+            jsonMapper
+        )
+    );
+  }
+
+  @Test
+  void testRejectsNullFingerprint()
+  {
+    Assertions.assertThrows(
+        NullPointerException.class,
+        () -> new CompositePartialLoadSpec(
+            DELEGATE,
+            List.of(projectionMember(List.of("a"), "v1:x")),
+            null,
+            jsonMapper
+        )
+    );
+  }
+
+  @Test
+  void testRejectsNullMembers()
+  {
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> new CompositePartialLoadSpec(DELEGATE, null, "v1:x", jsonMapper)
+    );
+  }
+
+  @Test
+  void testRejectsEmptyMembers()
+  {
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> new CompositePartialLoadSpec(DELEGATE, List.of(), "v1:x", 
jsonMapper)
+    );
+  }
+
+  @Test
+  void testRejectsMemberCarryingDelegate()
+  {
+    // Unstripped member: the composite owns the delegate, so silently 
overwriting it could mask a real mismatch.
+    final DruidException thrown = Assertions.assertThrows(
+        DruidException.class,
+        () -> new CompositePartialLoadSpec(
+            DELEGATE,
+            List.of(PartialProjectionLoadSpec.wireForm(DELEGATE, List.of("a"), 
"v1:x")),
+            "v1:x",
+            jsonMapper
+        )
+    );
+    Assertions.assertTrue(
+        thrown.getMessage().contains("must not carry its own [delegate]"),
+        "unexpected message: " + thrown.getMessage()
+    );
+  }
+
+  @Test
+  void testRejectsMemberWithNonPartialType()
+  {
+    final DruidException thrown = Assertions.assertThrows(
+        DruidException.class,
+        () -> new CompositePartialLoadSpec(
+            DELEGATE,
+            List.of(Map.of("type", "stub", "path", "/")),
+            "v1:x",
+            jsonMapper
+        )
+    );
+    Assertions.assertTrue(
+        thrown.getMessage().contains("must be a partial load spec with a type 
starting with"),
+        "unexpected message: " + thrown.getMessage()
+    );
+  }
+
+  private static final RowSignature CLUSTERING_TENANT = RowSignature.builder()
+                                                                    
.add("tenant", ColumnType.STRING)
+                                                                    .build();
+
+  private static SegmentFileMetadata clusteredMetadata(
+      List<TableClusterGroupSpec> groups,
+      @Nullable List<String> projections
+  )
+  {
+    final ClusteredValueGroupsBaseTableSchema baseSchema = 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
+    );
+    final int numRows = 
groups.stream().mapToInt(TableClusterGroupSpec::getNumRows).sum();
+    final List<ProjectionMetadata> projectionMetadata = new ArrayList<>();
+    projectionMetadata.add(new ProjectionMetadata(numRows, baseSchema));
+    if (projections != null) {
+      for (String name : projections) {
+        projectionMetadata.add(new ProjectionMetadata(numRows, 
projectionSchemaNamed(name)));
+      }
+    }
+    return new SegmentFileMetadata(List.of(), Map.of(), null, null, null, 
projectionMetadata, null);
+  }
+
+  private static SegmentFileMetadata projectionMetadata(List<String> 
projections)
+  {
+    final List<ProjectionMetadata> projectionMetadata = new ArrayList<>();
+    projectionMetadata.add(
+        new ProjectionMetadata(
+            100,
+            new TableProjectionSchema(
+                VirtualColumns.EMPTY,
+                List.of(ColumnHolder.TIME_COLUMN_NAME, "tenant"),
+                null,
+                List.of(OrderBy.ascending(ColumnHolder.TIME_COLUMN_NAME))
+            )
+        )
+    );
+    for (String name : projections) {
+      projectionMetadata.add(new ProjectionMetadata(10, 
projectionSchemaNamed(name)));
+    }
+    return new SegmentFileMetadata(List.of(), Map.of(), null, null, null, 
projectionMetadata, null);
+  }
+
+  private static AggregateProjectionSchema projectionSchemaNamed(String name)
+  {
+    return new AggregateProjectionSchema(
+        name,
+        null,
+        null,
+        VirtualColumns.EMPTY,
+        List.of("tenant"),
+        new AggregatorFactory[]{new CountAggregatorFactory("cnt")},
+        List.of(OrderBy.ascending("tenant"))
+    );
+  }
+
+  private static DataSegment clusteredSegment(List<List<Object>> tuples)
+  {
+    return DataSegment.builder(
+                          SegmentId.of("ds", Intervals.ETERNITY, "v1", new 
NumberedShardSpec(0, 1))
+                      )
+                      .size(0)
+                      .clusterGroups(new ClusterGroupTuples(CLUSTERING_TENANT, 
tuples))
+                      .build();
+  }
+
+  private static DataSegment unclusteredSegment()
+  {
+    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 d60ca44c12f..e3b615738fd 100644
--- a/server/src/main/java/org/apache/druid/guice/PartialLoadSpecModule.java
+++ b/server/src/main/java/org/apache/druid/guice/PartialLoadSpecModule.java
@@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.Module;
 import com.fasterxml.jackson.databind.module.SimpleModule;
 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.PartialClusterGroupLoadSpec;
 import org.apache.druid.segment.loading.PartialProjectionLoadSpec;
@@ -30,9 +31,9 @@ import 
org.apache.druid.segment.loading.PartialProjectionLoadSpec;
 import java.util.List;
 
 /**
- * Registers {@link PartialProjectionLoadSpec} and {@link 
PartialClusterGroupLoadSpec} 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 {@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.
  */
 public class PartialLoadSpecModule implements DruidModule
 {
@@ -48,7 +49,8 @@ public class PartialLoadSpecModule implements DruidModule
     return List.of(
         new SimpleModule().registerSubtypes(
             PartialProjectionLoadSpec.class,
-            PartialClusterGroupLoadSpec.class
+            PartialClusterGroupLoadSpec.class,
+            CompositePartialLoadSpec.class
         )
     );
   }
diff --git 
a/server/src/main/java/org/apache/druid/server/coordinator/rules/CompositePartialLoadMatcher.java
 
b/server/src/main/java/org/apache/druid/server/coordinator/rules/CompositePartialLoadMatcher.java
new file mode 100644
index 00000000000..de571e25c58
--- /dev/null
+++ 
b/server/src/main/java/org/apache/druid/server/coordinator/rules/CompositePartialLoadMatcher.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.server.coordinator.rules;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.hash.Hasher;
+import com.google.common.hash.Hashing;
+import com.google.common.io.BaseEncoding;
+import org.apache.druid.error.InvalidInput;
+import org.apache.druid.segment.loading.CompositePartialLoadSpec;
+import org.apache.druid.segment.loading.PartialLoadSpec;
+import org.apache.druid.timeline.DataSegment;
+
+import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Combines several {@link PartialLoadMatcher}s so that one {@link 
PartialLoadRule} can contribute more than one kind
+ * of partial load to a segment. The resolved selection is the <em>union</em> 
of what the members select: partial-load
+ * a segment's projections and a subset of its cluster groups together, or 
express a single scheme's selection that
+ * its own matcher cannot, such as a disjoint union of include/exclude pattern 
pairs over the same clustering columns.
+ * <p>
+ * Composition is a union, not an ordered fallback. Members are not consulted 
in priority order and no member can
+ * shadow another; every member that resolves contributes, and the 
historical-side {@link CompositePartialLoadSpec}
+ * takes the union of their bundles.
+ * <p>
+ * <b>A member that does not apply vetoes the whole composite.</b> If any 
member returns {@code null}, it understands
+ * neither the segment's shape nor how to express a selection for it, e.g. a 
cluster-group matcher facing a segment
+ * that isn't clustered, or a matcher type this Druid version doesn't 
recognize (see {@link UnknownPartialLoadMatcher})
+ * this matcher returns {@code null} too and the rule's {@link 
CannotMatchBehavior} decides for the whole segment.
+ * Skipping such a member instead would silently narrow the load: a composite 
whose cluster-group member went opaque
+ * would announce a segment holding only its projections and none of its rows, 
and queries against it would quietly
+ * return nothing.
+ */
+public class CompositePartialLoadMatcher implements PartialLoadMatcher
+{
+  public static final String TYPE = "composite";
+
+  static final String FINGERPRINT_VERSION = "v1";
+
+  private final List<PartialLoadMatcher> matchers;
+
+  @JsonCreator
+  public CompositePartialLoadMatcher(@JsonProperty("matchers") 
List<PartialLoadMatcher> matchers)
+  {
+    if (matchers == null || matchers.isEmpty()) {
+      throw InvalidInput.exception("matchers must not be null or empty for 
composite matcher");
+    }
+    for (int i = 0; i < matchers.size(); i++) {
+      if (matchers.get(i) == null) {
+        throw InvalidInput.exception("matchers[%s] must not be null for 
composite matcher", i);
+      }
+    }
+    this.matchers = List.copyOf(matchers);
+  }
+
+  @JsonProperty
+  public List<PartialLoadMatcher> getMatchers()
+  {
+    return matchers;
+  }
+
+  @Override
+  @Nullable
+  public MatchResult match(DataSegment segment, Map<String, Object> 
baseLoadSpec)
+  {
+    // Members get the real base load spec, not a stub: a member may 
legitimately inspect it, and its delegate is
+    // stripped afterward (see toMember) rather than withheld up front.
+    final List<MatchResult> results = new ArrayList<>(matchers.size());
+    for (PartialLoadMatcher matcher : matchers) {
+      final MatchResult result = matcher.match(segment, baseLoadSpec);
+      if (result == null) {
+        return null;
+      }
+      results.add(result);
+    }
+
+    if (results.size() == 1) {
+      // A single-member composite is exactly its member. Emitting the 
member's load spec verbatim keeps a rule that
+      // was wrapped in a composite fingerprint-identical to the same rule 
with the bare matcher, so wrapping does not
+      // re-fingerprint (and thus re-apply) every segment the rule covers.
+      return results.getFirst();
+    }
+
+    final List<Map<String, Object>> members = new ArrayList<>(results.size());
+    for (MatchResult result : results) {
+      members.add(toMember(result));
+    }
+    final String fingerprint = computeFingerprint(results);
+    return new MatchResult(
+        CompositePartialLoadSpec.wireForm(baseLoadSpec, members, fingerprint),
+        fingerprint
+    );
+  }
+
+  /**
+   * Converts a member's {@link MatchResult} into the composite's member load 
spec by dropping the
+   * {@link PartialLoadSpec#DELEGATE_FIELD} field. Every member of a composite 
describes a selection within the same
+   * segment, so the composite carries the backend load spec once at the top 
level and re-injects it when it
+   * materializes each member; repeating it per member would bloat every load 
request and announcement.
+   */
+  private static Map<String, Object> toMember(MatchResult result)
+  {
+    final Map<String, Object> member = new 
LinkedHashMap<>(result.wrappedLoadSpec());
+    member.remove(PartialLoadSpec.DELEGATE_FIELD);
+    return member;
+  }
+
+  /**
+   * Fingerprints the composite over its members' {@code (type, fingerprint)} 
pairs. Each member fingerprint already
+   * identifies that member's resolved selection within its own scheme, and 
the type disambiguates equal fingerprints
+   * produced by different schemes.
+   * <p>
+   * Pairs are sorted, so reordering a rule's {@code matchers} does not change 
the fingerprint — the resolved selection
+   * is a set union and therefore order-independent, and the cascade should 
not thrash on equivalent rule rewordings.
+   * <p>
+   * When every member resolved to an empty selection the composite reports 
{@link #EMPTY_LOAD_FINGERPRINT}, carrying
+   * the empty-load contract through composition rather than minting a 
distinct fingerprint for a load that puts no
+   * scheme-specific content on the historical.
+   * <p>
+   * Note that a composite of two same-scheme members does not fingerprint the 
same as a single matcher that resolved
+   * to the same union. The coordinator only compares a segment's fingerprint 
against the rule that requested it, so
+   * the difference costs at most one cheap rule re-apply on the historical, 
never a re-download.
+   */
+  private static String computeFingerprint(List<MatchResult> results)
+  {
+    boolean allEmpty = true;
+    final List<String> pairs = new ArrayList<>(results.size());
+    for (MatchResult result : results) {
+      allEmpty = allEmpty && 
EMPTY_LOAD_FINGERPRINT.equals(result.fingerprint());
+      pairs.add(memberType(result) + '\0' + result.fingerprint());
+    }
+    if (allEmpty) {
+      return EMPTY_LOAD_FINGERPRINT;
+    }
+    final Hasher hasher = Hashing.sha256().newHasher();
+    for (String pair : pairs.stream().sorted().toList()) {
+      hasher.putUnencodedChars(pair);
+      hasher.putByte((byte) 0);
+    }
+    final String hex = 
BaseEncoding.base16().encode(hasher.hash().asBytes()).toLowerCase(Locale.ROOT);
+    // should be good enough without dragging the whole thing around for every 
segment
+    return FINGERPRINT_VERSION + ":" + hex.substring(0, 16);
+  }
+
+  private static String memberType(MatchResult result)
+  {
+    return 
String.valueOf(result.wrappedLoadSpec().get(PartialLoadSpec.TYPE_FIELD));
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    CompositePartialLoadMatcher that = (CompositePartialLoadMatcher) o;
+    return Objects.equals(matchers, that.matchers);
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Objects.hash(matchers);
+  }
+
+  @Override
+  public String toString()
+  {
+    return "CompositePartialLoadMatcher{matchers=" + matchers + "}";
+  }
+}
diff --git 
a/server/src/main/java/org/apache/druid/server/coordinator/rules/PartialLoadMatcher.java
 
b/server/src/main/java/org/apache/druid/server/coordinator/rules/PartialLoadMatcher.java
index 5bc1690a20e..734ed45fbf6 100644
--- 
a/server/src/main/java/org/apache/druid/server/coordinator/rules/PartialLoadMatcher.java
+++ 
b/server/src/main/java/org/apache/druid/server/coordinator/rules/PartialLoadMatcher.java
@@ -38,7 +38,8 @@ import java.util.Map;
 @JsonSubTypes({
     @JsonSubTypes.Type(name = ExactProjectionPartialLoadMatcher.TYPE, value = 
ExactProjectionPartialLoadMatcher.class),
     @JsonSubTypes.Type(name = WildcardProjectionPartialLoadMatcher.TYPE, value 
= WildcardProjectionPartialLoadMatcher.class),
-    @JsonSubTypes.Type(name = WildcardClusterGroupPartialLoadMatcher.TYPE, 
value = WildcardClusterGroupPartialLoadMatcher.class)
+    @JsonSubTypes.Type(name = WildcardClusterGroupPartialLoadMatcher.TYPE, 
value = WildcardClusterGroupPartialLoadMatcher.class),
+    @JsonSubTypes.Type(name = CompositePartialLoadMatcher.TYPE, value = 
CompositePartialLoadMatcher.class)
 })
 public interface PartialLoadMatcher
 {
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 1d72d2a2ed4..e6c6c585451 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
@@ -69,6 +69,7 @@ import org.junit.jupiter.api.io.TempDir;
 import java.io.File;
 import java.io.IOException;
 import java.util.Arrays;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ThreadLocalRandom;
@@ -175,6 +176,7 @@ class SegmentLocalCacheManagerPartialRuleLoadTest
     jsonMapper = TestHelper.makeJsonMapper();
     jsonMapper.registerSubtypes(new NamedType(LocalLoadSpec.class, "local"));
     jsonMapper.registerSubtypes(new NamedType(PartialProjectionLoadSpec.class, 
PartialProjectionLoadSpec.TYPE));
+    jsonMapper.registerSubtypes(new NamedType(CompositePartialLoadSpec.class, 
CompositePartialLoadSpec.TYPE));
     jsonMapper.registerModule(new SegmentizerModule());
     jsonMapper.registerModules(new 
LocalDataStorageDruidModule().getJacksonModules());
     jsonMapper.setInjectableValues(
@@ -231,6 +233,71 @@ class SegmentLocalCacheManagerPartialRuleLoadTest
     Assertions.assertFalse(location.isReserved(aggId), "selected bundle should 
NOT be in staticCacheEntries");
   }
 
+  @Test
+  void testLoadCompositeWrapperInstallsRuleHoldsOnUnionOfMemberBundles() 
throws Exception
+  {
+    // A partialComposite wrapper must survive the whole historical-side path: 
materialize each member with the
+    // composite's delegate spliced in, union their bundles, then rule-hold 
and eagerly download every one. Cross-scheme
+    // unions (projections + cluster groups) are covered by 
CompositePartialLoadSpecTest; here both members are
+    // projections because this fixture is not clustered.
+    manager = makeManager(true, true);
+    final StorageLocation location = manager.getLocations().get(0);
+    final String compositeFingerprint = "v1:composite-bundle-test";
+
+    manager.load(compositeWrapperSegment(List.of(AGG_BUNDLE, 
OTHER_AGG_BUNDLE), compositeFingerprint));
+
+    final PartialSegmentMetadataCacheEntry metadata = 
weakReservedMetadata(location, SEGMENT_ID);
+    Assertions.assertTrue(metadata.isRuleHeld(), "rule must be applied to the 
metadata entry");
+    Assertions.assertEquals(compositeFingerprint, 
metadata.getRuleFingerprint());
+
+    // Every member's bundle is rule-held: the union, not just the first 
member's selection.
+    for (String bundleName : List.of(AGG_BUNDLE, OTHER_AGG_BUNDLE)) {
+      Assertions.assertTrue(
+          metadata.isBundleRuleHeld(bundleName),
+          "bundle[" + bundleName + "] should be rule-held by the composite 
rule"
+      );
+    }
+    // __base is reserved as a mount-time dependency of both members rather 
than by a rule hold of its own.
+    Assertions.assertFalse(
+        metadata.isBundleRuleHeld(Projections.BASE_TABLE_PROJECTION_NAME),
+        "__base is a mount-time dependency, not a rule-selected bundle"
+    );
+    for (String bundleName : List.of(AGG_BUNDLE, OTHER_AGG_BUNDLE, 
Projections.BASE_TABLE_PROJECTION_NAME)) {
+      Assertions.assertTrue(
+          location.isWeakReserved(new 
PartialSegmentBundleCacheEntryIdentifier(SEGMENT_ID, bundleName)),
+          "bundle[" + bundleName + "] should be weak-reserved by the composite 
rule"
+      );
+    }
+
+    // Eager downloads completed before load() returned, for both members and 
the shared dependency.
+    final PartialSegmentFileMapperV10 mapper = metadata.getFileMapper();
+    Assertions.assertNotNull(mapper, "metadata mount should produce a file 
mapper");
+    for (String bundleName : List.of(AGG_BUNDLE, OTHER_AGG_BUNDLE, 
Projections.BASE_TABLE_PROJECTION_NAME)) {
+      Assertions.assertTrue(
+          mapper.isBundleFullyDownloaded(bundleName),
+          "bundle[" + bundleName + "] must be fully downloaded eagerly"
+      );
+    }
+  }
+
+  @Test
+  void testLoadCompositeWrapperWithOverlappingMembersHoldsBundleOnce() throws 
Exception
+  {
+    // Two members selecting the same projection: getSelectedBundleNames 
dedupes, so this must behave exactly like a
+    // single selection rather than double-holding or failing.
+    manager = makeManager(true, true);
+    final StorageLocation location = manager.getLocations().get(0);
+
+    manager.load(compositeWrapperSegment(List.of(AGG_BUNDLE, AGG_BUNDLE), 
"v1:composite-overlap-test"));
+
+    final PartialSegmentMetadataCacheEntry metadata = 
weakReservedMetadata(location, SEGMENT_ID);
+    Assertions.assertTrue(metadata.isBundleRuleHeld(AGG_BUNDLE));
+    Assertions.assertFalse(
+        location.isWeakReserved(new 
PartialSegmentBundleCacheEntryIdentifier(SEGMENT_ID, OTHER_AGG_BUNDLE)),
+        "unselected bundle must not be reserved"
+    );
+  }
+
   @Test
   void testLoadDoesNotReserveNonSelectedBundles() throws Exception
   {
@@ -746,6 +813,33 @@ class SegmentLocalCacheManagerPartialRuleLoadTest
                       .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.
+   */
+  private DataSegment compositeWrapperSegment(List<String> 
projectionPerMember, String fingerprint)
+  {
+    final Map<String, Object> delegate = Map.of(
+        "type", "local",
+        "path", DEEP_STORAGE_DIR.getAbsolutePath()
+    );
+    final List<Map<String, Object>> members = projectionPerMember
+        .stream()
+        .map(projection -> {
+          final Map<String, Object> member = new HashMap<>(
+              PartialProjectionLoadSpec.wireForm(delegate, 
List.of(projection), fingerprint + ":" + projection)
+          );
+          member.remove(PartialLoadSpec.DELEGATE_FIELD);
+          return member;
+        })
+        .toList();
+    return DataSegment.builder(SEGMENT_ID)
+                      .shardSpec(NoneShardSpec.instance())
+                      .loadSpec(CompositePartialLoadSpec.wireForm(delegate, 
members, fingerprint))
+                      .size(0)
+                      .build();
+  }
+
   /**
    * A wrapper whose inner LoadSpec resolves via {@code LocalLoadSpec} against 
a directory that holds no V10 file, so
    * {@code openRangeReader()} returns {@code null}. Simulates the "backend 
doesn't support range reads" case.
diff --git 
a/server/src/test/java/org/apache/druid/server/coordinator/rules/CompositePartialLoadMatcherTest.java
 
b/server/src/test/java/org/apache/druid/server/coordinator/rules/CompositePartialLoadMatcherTest.java
new file mode 100644
index 00000000000..ba7f22bf897
--- /dev/null
+++ 
b/server/src/test/java/org/apache/druid/server/coordinator/rules/CompositePartialLoadMatcherTest.java
@@ -0,0 +1,457 @@
+/*
+ * 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.server.coordinator.rules;
+
+import com.fasterxml.jackson.databind.InjectableValues;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import nl.jqno.equalsverifier.EqualsVerifier;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.error.DruidExceptionMatcher;
+import org.apache.druid.jackson.DefaultObjectMapper;
+import org.apache.druid.java.util.common.Intervals;
+import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.segment.column.RowSignature;
+import org.apache.druid.segment.loading.CompositePartialLoadSpec;
+import org.apache.druid.segment.loading.PartialClusterGroupLoadSpec;
+import org.apache.druid.segment.loading.PartialLoadSpec;
+import org.apache.druid.segment.loading.PartialProjectionLoadSpec;
+import org.apache.druid.timeline.ClusterGroupTuples;
+import org.apache.druid.timeline.DataSegment;
+import org.apache.druid.timeline.SegmentId;
+import org.apache.druid.timeline.partition.NumberedShardSpec;
+import org.hamcrest.MatcherAssert;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import javax.annotation.Nullable;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Tests for {@link CompositePartialLoadMatcher}: the union of member 
selections, the veto when a member cannot match,
+ * single-member passthrough, and fingerprint stability.
+ */
+class CompositePartialLoadMatcherTest
+{
+  private static final Map<String, Object> BASE_LOAD_SPEC = Map.of("type", 
"local", "path", "/seg");
+
+  private final ObjectMapper mapper = new DefaultObjectMapper();
+
+  @BeforeEach
+  void setUp()
+  {
+    final InjectableValues.Std injectables = new InjectableValues.Std();
+    injectables.addValue(DataSegment.PruneSpecsHolder.class, 
DataSegment.PruneSpecsHolder.DEFAULT);
+    mapper.setInjectableValues(injectables);
+  }
+
+  @Test
+  void testConstructorRejectsNullMatchers()
+  {
+    MatcherAssert.assertThat(
+        Assertions.assertThrows(DruidException.class, () -> new 
CompositePartialLoadMatcher(null)),
+        DruidExceptionMatcher.invalidInput().expectMessageContains("matchers 
must not be null or empty")
+    );
+  }
+
+  @Test
+  void testConstructorRejectsEmptyMatchers()
+  {
+    MatcherAssert.assertThat(
+        Assertions.assertThrows(DruidException.class, () -> new 
CompositePartialLoadMatcher(List.of())),
+        DruidExceptionMatcher.invalidInput().expectMessageContains("matchers 
must not be null or empty")
+    );
+  }
+
+  @Test
+  void testConstructorRejectsNullMember()
+  {
+    MatcherAssert.assertThat(
+        Assertions.assertThrows(
+            DruidException.class,
+            () -> new 
CompositePartialLoadMatcher(Arrays.asList(exactProjection("p"), null))
+        ),
+        
DruidExceptionMatcher.invalidInput().expectMessageContains("matchers[1] must 
not be null")
+    );
+  }
+
+  @Test
+  void testUnionsAcrossSchemes()
+  {
+    final DataSegment segment = 
clusteredSegmentWithProjections(List.of("user_hourly", "user_daily"));
+    final CompositePartialLoadMatcher matcher = new 
CompositePartialLoadMatcher(List.of(
+        exactProjection("user_hourly"),
+        globClusterGroup(Map.of("tenant", "acme"))
+    ));
+
+    final PartialLoadMatcher.MatchResult result = matcher.match(segment, 
BASE_LOAD_SPEC);
+    Assertions.assertNotNull(result);
+    Assertions.assertEquals(CompositePartialLoadSpec.TYPE, 
result.wrappedLoadSpec().get("type"));
+    Assertions.assertEquals(BASE_LOAD_SPEC, 
result.wrappedLoadSpec().get("delegate"));
+    Assertions.assertEquals(
+        List.of(
+            Map.of(
+                "type", PartialProjectionLoadSpec.TYPE,
+                "projections", List.of("user_hourly"),
+                "fingerprint", exactProjection("user_hourly").match(segment, 
BASE_LOAD_SPEC).fingerprint()
+            ),
+            Map.of(
+                "type", PartialClusterGroupLoadSpec.TYPE,
+                "clusterGroupIndices", List.of(0, 1),
+                "fingerprint", globClusterGroup(Map.of("tenant", 
"acme")).match(segment, BASE_LOAD_SPEC).fingerprint()
+            )
+        ),
+        members(result)
+    );
+  }
+
+  @Test
+  void testUnionsTwoSameSchemeMembers()
+  {
+    final DataSegment segment = clusteredSegmentWithProjections(null);
+    final CompositePartialLoadMatcher matcher = new 
CompositePartialLoadMatcher(List.of(
+        globClusterGroup(Map.of("tenant", "acme", "region", "us-east-1")),
+        globClusterGroup(Map.of("tenant", "globex", "region", "us-east-1"))
+    ));
+
+    final PartialLoadMatcher.MatchResult result = matcher.match(segment, 
BASE_LOAD_SPEC);
+    Assertions.assertNotNull(result);
+    Assertions.assertEquals(
+        List.of(List.of(0), List.of(2)),
+        members(result).stream().map(m -> 
m.get("clusterGroupIndices")).toList()
+    );
+  }
+
+  @Test
+  void testUnionsPerBranchIncludeExcludePairs()
+  {
+    // A single globClusterGroup has one excludePatterns list, applied to 
every include. That makes an
+    // include/exclude pair unexpressible as a unit whenever an exclude isn't 
qualified by its own branch's include:
+    //   branch A — every acme region except us-west-2
+    //   branch B — every globex region except us-east-1
+    // Collapsing these into one matcher means both excludes apply to both 
includes, which over-excludes. Each branch
+    // becomes its own composite member instead.
+    final DataSegment segment = fourGroupSegment();
+    final PartialLoadMatcher branchA = globClusterGroup(
+        List.of(Map.of("tenant", "acme")),
+        List.of(Map.of("region", "us-west-2"))
+    );
+    final PartialLoadMatcher branchB = globClusterGroup(
+        List.of(Map.of("tenant", "globex")),
+        List.of(Map.of("region", "us-east-1"))
+    );
+
+    final PartialLoadMatcher.MatchResult result =
+        new CompositePartialLoadMatcher(List.of(branchA, 
branchB)).match(segment, BASE_LOAD_SPEC);
+    Assertions.assertNotNull(result);
+    Assertions.assertEquals(
+        // (acme, us-east-1) from branch A; (globex, us-west-2) from branch B
+        List.of(List.of(0), List.of(3)),
+        members(result).stream().map(m -> 
m.get("clusterGroupIndices")).toList()
+    );
+
+    // The collapsed single matcher is not equivalent: pooling the excludes 
wipes out every group.
+    final PartialLoadMatcher.MatchResult collapsed = globClusterGroup(
+        List.of(Map.of("tenant", "acme"), Map.of("tenant", "globex")),
+        List.of(Map.of("region", "us-west-2"), Map.of("region", "us-east-1"))
+    ).match(segment, BASE_LOAD_SPEC);
+    Assertions.assertNotNull(collapsed);
+    Assertions.assertEquals(List.of(), 
collapsed.wrappedLoadSpec().get("clusterGroupIndices"));
+  }
+
+  @Test
+  void testMembersCarryNoDelegate()
+  {
+    // The composite carries the backend load spec exactly once, at the top 
level.
+    final DataSegment segment = 
clusteredSegmentWithProjections(List.of("user_hourly"));
+    final CompositePartialLoadMatcher matcher = new 
CompositePartialLoadMatcher(List.of(
+        exactProjection("user_hourly"),
+        globClusterGroup(Map.of("tenant", "acme"))
+    ));
+    final PartialLoadMatcher.MatchResult result = matcher.match(segment, 
BASE_LOAD_SPEC);
+    Assertions.assertNotNull(result);
+    for (Map<String, Object> member : members(result)) {
+      Assertions.assertFalse(
+          member.containsKey(PartialLoadSpec.DELEGATE_FIELD),
+          "member should not carry a delegate: " + member
+      );
+    }
+  }
+
+  @Test
+  void testNullMemberVetoesWholeComposite()
+  {
+    // The cluster-group member cannot reason about a non-clustered segment. 
Skipping it would announce a segment
+    // holding only its projections and none of its rows, so the composite 
goes opaque and the rule's
+    // CannotMatchBehavior decides.
+    final DataSegment segment = 
unclusteredSegmentWithProjections(List.of("user_hourly"));
+    Assertions.assertNotNull(exactProjection("user_hourly").match(segment, 
BASE_LOAD_SPEC));
+    Assertions.assertNull(globClusterGroup(Map.of("tenant", 
"acme")).match(segment, BASE_LOAD_SPEC));
+
+    final CompositePartialLoadMatcher matcher = new 
CompositePartialLoadMatcher(List.of(
+        exactProjection("user_hourly"),
+        globClusterGroup(Map.of("tenant", "acme"))
+    ));
+    Assertions.assertNull(matcher.match(segment, BASE_LOAD_SPEC));
+  }
+
+  @Test
+  void testUnknownMemberVetoesWholeComposite()
+  {
+    // A matcher type this Druid version doesn't recognize deserializes to 
UnknownPartialLoadMatcher, whose match()
+    // returns null. The composite must escalate rather than silently narrow 
the load.
+    final DataSegment segment = 
clusteredSegmentWithProjections(List.of("user_hourly"));
+    final CompositePartialLoadMatcher matcher = new 
CompositePartialLoadMatcher(List.of(
+        exactProjection("user_hourly"),
+        new UnknownPartialLoadMatcher()
+    ));
+    Assertions.assertNull(matcher.match(segment, BASE_LOAD_SPEC));
+  }
+
+  @Test
+  void testSingleMemberPassesThroughVerbatim()
+  {
+    // Wrapping a matcher in a one-element composite must not change the load 
spec or the fingerprint, so wrapping an
+    // existing rule doesn't re-fingerprint every segment it covers.
+    final DataSegment segment = 
clusteredSegmentWithProjections(List.of("user_hourly"));
+    final PartialLoadMatcher bare = exactProjection("user_hourly");
+    final PartialLoadMatcher.MatchResult bareResult = bare.match(segment, 
BASE_LOAD_SPEC);
+    final PartialLoadMatcher.MatchResult wrappedResult =
+        new CompositePartialLoadMatcher(List.of(bare)).match(segment, 
BASE_LOAD_SPEC);
+
+    Assertions.assertEquals(bareResult, wrappedResult);
+    Assertions.assertEquals(PartialProjectionLoadSpec.TYPE, 
wrappedResult.wrappedLoadSpec().get("type"));
+  }
+
+  @Test
+  void testAllEmptyMembersReportEmptyFingerprint()
+  {
+    // Every member resolved to an empty selection: the empty-load contract 
carries through composition.
+    final DataSegment segment = clusteredSegmentWithProjections(null);
+    final CompositePartialLoadMatcher matcher = new 
CompositePartialLoadMatcher(List.of(
+        globClusterGroup(Map.of("tenant", "nobody")),
+        globClusterGroup(Map.of("tenant", "nobody-else"))
+    ));
+    final PartialLoadMatcher.MatchResult result = matcher.match(segment, 
BASE_LOAD_SPEC);
+    Assertions.assertNotNull(result);
+    Assertions.assertEquals(PartialLoadMatcher.EMPTY_LOAD_FINGERPRINT, 
result.fingerprint());
+    Assertions.assertEquals(
+        List.of(List.of(), List.of()),
+        members(result).stream().map(m -> 
m.get("clusterGroupIndices")).toList()
+    );
+  }
+
+  @Test
+  void testOneEmptyMemberDoesNotMakeCompositeEmpty()
+  {
+    final DataSegment segment = clusteredSegmentWithProjections(null);
+    final CompositePartialLoadMatcher matcher = new 
CompositePartialLoadMatcher(List.of(
+        globClusterGroup(Map.of("tenant", "acme")),
+        globClusterGroup(Map.of("tenant", "nobody"))
+    ));
+    final PartialLoadMatcher.MatchResult result = matcher.match(segment, 
BASE_LOAD_SPEC);
+    Assertions.assertNotNull(result);
+    Assertions.assertNotEquals(PartialLoadMatcher.EMPTY_LOAD_FINGERPRINT, 
result.fingerprint());
+  }
+
+  @Test
+  void testFingerprintStableAcrossMatcherReordering()
+  {
+    // The resolved selection is a set union, so reordering the matchers must 
not thrash the cascade.
+    final DataSegment segment = 
clusteredSegmentWithProjections(List.of("user_hourly"));
+    final PartialLoadMatcher projection = exactProjection("user_hourly");
+    final PartialLoadMatcher clusterGroup = globClusterGroup(Map.of("tenant", 
"acme"));
+
+    final String forward = new CompositePartialLoadMatcher(List.of(projection, 
clusterGroup))
+        .match(segment, BASE_LOAD_SPEC).fingerprint();
+    final String reversed = new 
CompositePartialLoadMatcher(List.of(clusterGroup, projection))
+        .match(segment, BASE_LOAD_SPEC).fingerprint();
+    Assertions.assertEquals(forward, reversed);
+  }
+
+  @Test
+  void testFingerprintDiffersOnDifferentMemberContent()
+  {
+    final DataSegment segment = 
clusteredSegmentWithProjections(List.of("user_hourly", "user_daily"));
+    final String hourly = new CompositePartialLoadMatcher(List.of(
+        exactProjection("user_hourly"),
+        globClusterGroup(Map.of("tenant", "acme"))
+    )).match(segment, BASE_LOAD_SPEC).fingerprint();
+    final String daily = new CompositePartialLoadMatcher(List.of(
+        exactProjection("user_daily"),
+        globClusterGroup(Map.of("tenant", "acme"))
+    )).match(segment, BASE_LOAD_SPEC).fingerprint();
+    Assertions.assertNotEquals(hourly, daily);
+  }
+
+  @Test
+  void testFingerprintDiffersFromMemberFingerprints()
+  {
+    // Sanity: the composite mints its own fingerprint rather than reusing a 
member's, so a rule swap between a
+    // composite and one of its members is detected.
+    final DataSegment segment = 
clusteredSegmentWithProjections(List.of("user_hourly"));
+    final PartialLoadMatcher projection = exactProjection("user_hourly");
+    final PartialLoadMatcher clusterGroup = globClusterGroup(Map.of("tenant", 
"acme"));
+    final String composite = new 
CompositePartialLoadMatcher(List.of(projection, clusterGroup))
+        .match(segment, BASE_LOAD_SPEC).fingerprint();
+    Assertions.assertNotEquals(projection.match(segment, 
BASE_LOAD_SPEC).fingerprint(), composite);
+    Assertions.assertNotEquals(clusterGroup.match(segment, 
BASE_LOAD_SPEC).fingerprint(), composite);
+  }
+
+  @Test
+  void testNestedComposite()
+  {
+    final DataSegment segment = 
clusteredSegmentWithProjections(List.of("user_hourly"));
+    final CompositePartialLoadMatcher matcher = new 
CompositePartialLoadMatcher(List.of(
+        exactProjection("user_hourly"),
+        new CompositePartialLoadMatcher(List.of(
+            globClusterGroup(Map.of("tenant", "acme", "region", "us-east-1")),
+            globClusterGroup(Map.of("tenant", "globex", "region", "us-east-1"))
+        ))
+    ));
+    final PartialLoadMatcher.MatchResult result = matcher.match(segment, 
BASE_LOAD_SPEC);
+    Assertions.assertNotNull(result);
+    final List<Map<String, Object>> members = members(result);
+    Assertions.assertEquals(PartialProjectionLoadSpec.TYPE, 
members.get(0).get("type"));
+    Assertions.assertEquals(CompositePartialLoadSpec.TYPE, 
members.get(1).get("type"));
+    // The nested composite's own members are likewise delegate-free.
+    @SuppressWarnings("unchecked")
+    final List<Map<String, Object>> nested = (List<Map<String, Object>>) 
members.get(1).get("members");
+    for (Map<String, Object> m : nested) {
+      Assertions.assertFalse(m.containsKey(PartialLoadSpec.DELEGATE_FIELD), 
"nested member has a delegate: " + m);
+    }
+  }
+
+  @Test
+  void testJsonRoundTrip() throws Exception
+  {
+    final PartialLoadMatcher matcher = new CompositePartialLoadMatcher(List.of(
+        exactProjection("user_hourly"),
+        globClusterGroup(Map.of("tenant", "acme"))
+    ));
+    final String json = mapper.writeValueAsString(matcher);
+    final PartialLoadMatcher reread = mapper.readValue(json, 
PartialLoadMatcher.class);
+    Assertions.assertInstanceOf(CompositePartialLoadMatcher.class, reread);
+    Assertions.assertEquals(matcher, reread);
+  }
+
+  @Test
+  void testJsonRoundTripNested() throws Exception
+  {
+    final PartialLoadMatcher matcher = new CompositePartialLoadMatcher(List.of(
+        exactProjection("user_hourly"),
+        new 
CompositePartialLoadMatcher(List.of(globClusterGroup(Map.of("tenant", "acme"))))
+    ));
+    final String json = mapper.writeValueAsString(matcher);
+    Assertions.assertEquals(matcher, mapper.readValue(json, 
PartialLoadMatcher.class));
+  }
+
+  @Test
+  void testEquals()
+  {
+    
EqualsVerifier.forClass(CompositePartialLoadMatcher.class).usingGetClass().verify();
+  }
+
+  @SuppressWarnings("unchecked")
+  private static List<Map<String, Object>> 
members(PartialLoadMatcher.MatchResult result)
+  {
+    return (List<Map<String, Object>>) result.wrappedLoadSpec().get("members");
+  }
+
+  private static PartialLoadMatcher exactProjection(String name)
+  {
+    return new ExactProjectionPartialLoadMatcher(List.of(name));
+  }
+
+  private static PartialLoadMatcher globClusterGroup(Map<String, String> 
pattern)
+  {
+    return new WildcardClusterGroupPartialLoadMatcher(List.of(pattern), null);
+  }
+
+  private static PartialLoadMatcher globClusterGroup(
+      List<Map<String, String>> patterns,
+      List<Map<String, String>> excludePatterns
+  )
+  {
+    return new WildcardClusterGroupPartialLoadMatcher(patterns, 
excludePatterns);
+  }
+
+  private static RowSignature tenantRegion()
+  {
+    return RowSignature.builder()
+                       .add("tenant", ColumnType.STRING)
+                       .add("region", ColumnType.STRING)
+                       .build();
+  }
+
+  /** A 3-group fixture: (acme, us-east-1), (acme, us-west-2), (globex, 
us-east-1). */
+  private static DataSegment clusteredSegmentWithProjections(@Nullable 
List<String> projections)
+  {
+    final DataSegment.Builder builder = baseBuilder()
+        .clusterGroups(new ClusterGroupTuples(
+            tenantRegion(),
+            List.of(
+                List.of("acme", "us-east-1"),
+                List.of("acme", "us-west-2"),
+                List.of("globex", "us-east-1")
+            )
+        ));
+    if (projections != null) {
+      builder.projections(projections);
+    }
+    return builder.build();
+  }
+
+  /**
+   * A 4-group fixture spanning both tenants in both regions — (acme, 
us-east-1), (acme, us-west-2),
+   * (globex, us-east-1), (globex, us-west-2) — so a region-only exclude 
scoped to one tenant's branch has something
+   * to over-exclude in the other's if the two branches are pooled into a 
single matcher.
+   */
+  private static DataSegment fourGroupSegment()
+  {
+    return baseBuilder()
+        .clusterGroups(new ClusterGroupTuples(
+            tenantRegion(),
+            List.of(
+                List.of("acme", "us-east-1"),
+                List.of("acme", "us-west-2"),
+                List.of("globex", "us-east-1"),
+                List.of("globex", "us-west-2")
+            )
+        ))
+        .build();
+  }
+
+  private static DataSegment unclusteredSegmentWithProjections(List<String> 
projections)
+  {
+    return baseBuilder().projections(projections).build();
+  }
+
+  private static DataSegment.Builder baseBuilder()
+  {
+    final NumberedShardSpec shardSpec = new NumberedShardSpec(0, 1);
+    return DataSegment.builder(SegmentId.of("ds", 
Intervals.of("2026-01-01/2026-01-02"), "v", shardSpec))
+                      .shardSpec(shardSpec)
+                      .loadSpec(BASE_LOAD_SPEC)
+                      .size(0);
+  }
+}
diff --git 
a/server/src/test/java/org/apache/druid/server/coordinator/rules/WildcardClusterGroupPartialLoadMatcherTest.java
 
b/server/src/test/java/org/apache/druid/server/coordinator/rules/WildcardClusterGroupPartialLoadMatcherTest.java
index 4c69e98c3ab..331dd6ac488 100644
--- 
a/server/src/test/java/org/apache/druid/server/coordinator/rules/WildcardClusterGroupPartialLoadMatcherTest.java
+++ 
b/server/src/test/java/org/apache/druid/server/coordinator/rules/WildcardClusterGroupPartialLoadMatcherTest.java
@@ -21,6 +21,7 @@ package org.apache.druid.server.coordinator.rules;
 
 import com.fasterxml.jackson.databind.InjectableValues;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import nl.jqno.equalsverifier.EqualsVerifier;
 import org.apache.druid.error.DruidException;
 import org.apache.druid.jackson.DefaultObjectMapper;
 import org.apache.druid.java.util.common.Intervals;
@@ -472,6 +473,15 @@ class WildcardClusterGroupPartialLoadMatcherTest
     Assertions.assertEquals(original, back);
   }
 
+  @Test
+  void testEquals()
+  {
+    EqualsVerifier.forClass(WildcardClusterGroupPartialLoadMatcher.class)
+                  .withIgnoredFields("compiledPatterns", 
"compiledExcludePatterns")
+                  .usingGetClass()
+                  .verify();
+  }
+
   @Test
   void testVirtualColumnsOmittedFromJsonWhenEmpty() throws Exception
   {


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

Reply via email to