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 669e6d1d42b minor: add AcquireMode.PARTIAL_RULE_HELD for testing and
experimentation (#20617)
669e6d1d42b is described below
commit 669e6d1d42bca494271af1e60b081c655af6240b
Author: Clint Wylie <[email protected]>
AuthorDate: Wed Oct 21 04:31:49 2026 -0700
minor: add AcquireMode.PARTIAL_RULE_HELD for testing and experimentation
(#20617)
---
.../query/PartialProjectionLoadRuleQueryTest.java | 40 ++-
.../java/org/apache/druid/query/QueryContext.java | 4 +-
.../query/context/QueryContextParameters.java | 1 +
.../druid/segment/PartialQueryableIndex.java | 57 +++-
.../PartialQueryableIndexCursorFactory.java | 45 ++-
.../segment/PartialQueryableIndexSegment.java | 21 +-
.../segment/file/PartialSegmentFileMapperV10.java | 32 +-
.../apache/druid/segment/loading/AcquireMode.java | 19 +-
.../org/apache/druid/query/QueryContextTest.java | 7 +
...eIndexCursorFactoryClusteredProjectionTest.java | 79 ++++-
...alQueryableIndexCursorFactoryClusteredTest.java | 59 ++++
.../PartialQueryableIndexServableBundlesTest.java | 353 +++++++++++++++++++++
.../loading/PartialSegmentBundleCacheEntry.java | 29 +-
.../loading/PartialSegmentMetadataCacheEntry.java | 33 +-
.../druid/segment/loading/SegmentCacheManager.java | 8 +-
.../segment/loading/SegmentLocalCacheManager.java | 51 ++-
.../segment/loading/VirtualPlaceholderSegment.java | 7 +-
...egmentLocalCacheManagerPartialRuleLoadTest.java | 317 +++++++++++++++++-
18 files changed, 1110 insertions(+), 52 deletions(-)
diff --git
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/PartialProjectionLoadRuleQueryTest.java
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/PartialProjectionLoadRuleQueryTest.java
index 90486463e26..1c13cc77c9b 100644
---
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/PartialProjectionLoadRuleQueryTest.java
+++
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/PartialProjectionLoadRuleQueryTest.java
@@ -42,6 +42,7 @@ import org.apache.druid.query.QueryContext;
import org.apache.druid.query.aggregation.LongMinAggregatorFactory;
import org.apache.druid.query.aggregation.LongSumAggregatorFactory;
import org.apache.druid.query.context.QueryContextParameters;
+import org.apache.druid.segment.loading.AcquireMode;
import org.apache.druid.server.coordinator.rules.CannotMatchBehavior;
import org.apache.druid.server.coordinator.rules.ForeverLoadRule;
import org.apache.druid.server.coordinator.rules.ForeverPartialLoadRule;
@@ -308,6 +309,38 @@ class PartialProjectionLoadRuleQueryTest extends
EmbeddedClusterTestBase
);
}
+ @Test
+ void testRuleHeldAcquireModeReadsOnlyRuleLoadedBundles()
+ {
+ // With segmentAcquireMode=PARTIAL_RULE_HELD the MSQ leaves only read the
bundles the partial-load rule selected.
+ // The projection is selected, so a query it can serve returns its usual
results. The cluster groups are not, so a
+ // projection miss reads no rows at all, rather than loading them on
demand as in
+ // testProjectionMissLoadsBaseBundleOnDemand (or reading them if an
earlier test already did). Neither query loads
+ // anything.
+ final LatchableEmitter emitter = quiesceAndFlushStorageMonitor();
+ final Map<String, Object> context =
+ QueryContext.ofMap(QueryContextParameters.SEGMENT_ACQUIRE_MODE,
AcquireMode.PARTIAL_RULE_HELD);
+
+ runDartQuery(
+ "SELECT \"countryName\", SUM(\"delta\") FROM \"" + dataSource + "\"
GROUP BY 1 ORDER BY 1",
+ context,
+ "CA,3\nFR,7\nUS,17"
+ );
+ runDartQuery(
+ "SELECT \"channel\", SUM(\"delta\") FROM \"" + dataSource + "\" GROUP
BY 1 ORDER BY 1",
+ context,
+ ""
+ );
+
+ emitter.awaitMetricQuiescent(StorageMonitor.VSF_LOAD_BEGIN_COUNT,
MONITOR_QUIESCE_TIMEOUT_MILLIS);
+
+ Assertions.assertEquals(
+ 0L,
+ emitter.getMetricEventLongSum(StorageMonitor.VSF_LOAD_COUNT),
+ "expected no on-demand file loads under PARTIAL_RULE_HELD"
+ );
+ }
+
@Test
void testRuleTransitionBetweenPartialAndFullLoad()
{
@@ -454,13 +487,18 @@ class PartialProjectionLoadRuleQueryTest extends
EmbeddedClusterTestBase
* only download the files the query actually reads) and asserts the CSV
result matches {@code expectedCsv}.
*/
private void runDartQuery(String sql, String expectedCsv)
+ {
+ runDartQuery(sql, Map.of(), expectedCsv);
+ }
+
+ private void runDartQuery(String sql, Map<String, Object> context, String
expectedCsv)
{
final String sqlQueryId = UUID.randomUUID().toString();
final String result;
try {
result = msqApis.submitDartSqlAsync(
sql,
- QueryContext.ofMap(QueryContextParameters.SQL_QUERY_ID, sqlQueryId),
+
QueryContext.builder().putAll(context).put(QueryContextParameters.SQL_QUERY_ID,
sqlQueryId).toMap(),
broker
).get();
}
diff --git a/processing/src/main/java/org/apache/druid/query/QueryContext.java
b/processing/src/main/java/org/apache/druid/query/QueryContext.java
index 6fc005cc9bf..b5ca45c04ec 100644
--- a/processing/src/main/java/org/apache/druid/query/QueryContext.java
+++ b/processing/src/main/java/org/apache/druid/query/QueryContext.java
@@ -906,7 +906,9 @@ public class QueryContext
* {@link AcquireMode#PARTIAL} degrades to {@link AcquireMode#FULL} where
partial downloads are unavailable, such as
* when they are disabled by configuration or the segment is not
range-readable. Forcing {@link AcquireMode#PARTIAL}
* where the engine defaults to {@link AcquireMode#FULL} fails at cursor
creation if the engine reads segments through
- * the synchronous cursor API.
+ * the synchronous cursor API. The same applies to {@link
AcquireMode#PARTIAL_RULE_HELD}, which never degrades: it
+ * reads only the bundles selected by each segment's partial-load rule, so
it is only meaningful on servers that apply
+ * those rules (Historicals) and reads nothing elsewhere.
*/
public AcquireMode getSegmentAcquireMode(AcquireMode defaultValue)
{
diff --git
a/processing/src/main/java/org/apache/druid/query/context/QueryContextParameters.java
b/processing/src/main/java/org/apache/druid/query/context/QueryContextParameters.java
index 62cdd7edab3..e5461adb978 100644
---
a/processing/src/main/java/org/apache/druid/query/context/QueryContextParameters.java
+++
b/processing/src/main/java/org/apache/druid/query/context/QueryContextParameters.java
@@ -1832,6 +1832,7 @@ public final class QueryContextParameters
Overrides how leaf stages acquire segments from the segment
cache.
`FULL` downloads each segment up front, while `PARTIAL`
downloads only the parts a query reads.
`PARTIAL` falls back to `FULL` where partial downloads are
unavailable.
+ `PARTIAL_RULE_HELD` reads only the parts a segment's
partial-load rule selected, and nothing from segments without one.
Intended for testing, debugging and performance comparison.
"""
)
diff --git
a/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndex.java
b/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndex.java
index 703254f1434..f81d8767334 100644
---
a/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndex.java
+++
b/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndex.java
@@ -65,6 +65,7 @@ import java.util.Set;
import java.util.SortedSet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
+import java.util.function.Predicate;
import java.util.stream.Collectors;
/**
@@ -353,6 +354,31 @@ public class PartialQueryableIndex implements
QueryableIndex
);
}
+ /**
+ * {@link #getProjection} restricted to the projections whose bundle {@code
servableBundles} accepts.
+ */
+ @Nullable
+ private QueryableProjection<QueryableIndex> getServableProjection(
+ CursorBuildSpec cursorBuildSpec,
+ Predicate<String> servableBundles
+ )
+ {
+ final SortedSet<AggregateProjectionMetadata> servable =
+ new ObjectAVLTreeSet<>(AggregateProjectionMetadata.COMPARATOR);
+ for (AggregateProjectionMetadata projection : projections) {
+ if (servableBundles.test(projection.getSchema().getName())) {
+ servable.add(projection);
+ }
+ }
+ return Projections.findMatchingProjection(
+ cursorBuildSpec,
+ servable,
+ dataInterval,
+ this::checkProjectionHasColumn,
+ this::getProjectionQueryableIndex
+ );
+ }
+
private boolean checkProjectionHasColumn(String projectionName, String
columnName)
{
final ProjectionMetadata meta = projectionSpecs.get(projectionName);
@@ -524,14 +550,31 @@ public class PartialQueryableIndex implements
QueryableIndex
*/
CursorPrefetchPlan planCursorPrefetch(CursorBuildSpec spec)
{
- final QueryableProjection<QueryableIndex> matched = getProjection(spec);
+ return planCursorPrefetch(spec, null);
+ }
+
+ /**
+ * Like {@link #planCursorPrefetch(CursorBuildSpec)}, but when {@code
servableBundles} is non-null, plans only over
+ * the bundles it accepts, as if they were the whole segment. Only accepted
projections are matching candidates, and
+ * only accepted cluster groups are planned. If the plan would otherwise
need an unaccepted base table bundle, it has
+ * no bundles at all, meaning the spec reads no rows. A matched projection's
base table parent bundle is a dependency
+ * of the projection rather than a row selector of its own, so it is planned
regardless.
+ */
+ CursorPrefetchPlan planCursorPrefetch(CursorBuildSpec spec, @Nullable
Predicate<String> servableBundles)
+ {
+ final QueryableProjection<QueryableIndex> matched = servableBundles == null
+ ? getProjection(spec)
+ :
getServableProjection(spec, servableBundles);
if (matched == null && clusteredBaseSummary != null) {
// Clustered base table: metadata-only group resolution; only the groups
whose clustering tuples survive the
// query's filters plan any downloads, one bundle per surviving group.
- final ClusterGroupQueryPlan clusterGroupPlan =
Projections.planClusterGroupQuery(
- new ArrayList<>(getClusterGroupSchemas()),
- spec
- );
+ final List<TableClusterGroupSpec> candidateGroups = new
ArrayList<>(getClusterGroupSchemas());
+ if (servableBundles != null) {
+ candidateGroups.removeIf(
+ group ->
!servableBundles.test(Projections.getClusterGroupBundleName(group.getClusteringValueIds()))
+ );
+ }
+ final ClusterGroupQueryPlan clusterGroupPlan =
Projections.planClusterGroupQuery(candidateGroups, spec);
final List<PrefetchBundle> bundles = new
ArrayList<>(clusterGroupPlan.survivingGroups().size());
for (TableClusterGroupSpec group : clusterGroupPlan.survivingGroups()) {
final QueryableIndex groupIndex = getClusterGroupQueryableIndex(group,
true);
@@ -554,6 +597,10 @@ public class PartialQueryableIndex implements
QueryableIndex
// parents.
final QueryableIndex rowSelector = matched != null ?
matched.getRowSelector() : this;
final String bundleName = matched != null ? matched.getName() :
Projections.BASE_TABLE_PROJECTION_NAME;
+ if (servableBundles != null && !servableBundles.test(bundleName)) {
+ // only reachable for the base table, since a matched projection was
already accepted
+ return new CursorPrefetchPlan(null, null, List.of());
+ }
final Set<String> required = requiredColumns(rowSelector, matched, spec);
final List<PrefetchBundle> bundles = new ArrayList<>(2);
bundles.add(
diff --git
a/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndexCursorFactory.java
b/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndexCursorFactory.java
index 2542d80424a..5e9e573d0e0 100644
---
a/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndexCursorFactory.java
+++
b/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndexCursorFactory.java
@@ -39,6 +39,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Predicate;
import java.util.function.Supplier;
/**
@@ -70,22 +71,42 @@ import java.util.function.Supplier;
* <b>Parallelism.</b> Every planned range read is its own task on the
download executor, so a bundle's independent
* deep-storage requests proceed concurrently rather than paying their round
trips sequentially. Once every run is
* resident, the ready callback materializes each bundle's required columns
and the cursor holder is constructed.
+ * <p>
+ * <b>Restricted serving.</b> When constructed with a {@code servableBundles}
predicate (see
+ * {@link org.apache.druid.segment.loading.AcquireMode#PARTIAL_RULE_HELD}),
both paths plan only over the bundles it
+ * accepts, and a spec that nothing servable can answer gets an {@link
EmptyCursorHolder}. Accepted bundles that are not
+ * resident are still downloaded by the async path as usual.
*/
public class PartialQueryableIndexCursorFactory implements CursorFactory
{
private final PartialQueryableIndex index;
private final QueryableIndexCursorFactory delegate;
private final PartialBundleAcquirer bundleAcquirer;
+ // when non-null, only the bundles this accepts are served, as if they were
the whole segment; see
+ // PartialQueryableIndex#planCursorPrefetch(CursorBuildSpec, Predicate)
+ @Nullable
+ private final Predicate<String> servableBundles;
public PartialQueryableIndexCursorFactory(
PartialQueryableIndex index,
TimeBoundaryInspector timeBoundaryInspector,
PartialBundleAcquirer bundleAcquirer
)
+ {
+ this(index, timeBoundaryInspector, bundleAcquirer, null);
+ }
+
+ public PartialQueryableIndexCursorFactory(
+ PartialQueryableIndex index,
+ TimeBoundaryInspector timeBoundaryInspector,
+ PartialBundleAcquirer bundleAcquirer,
+ @Nullable Predicate<String> servableBundles
+ )
{
this.index = index;
this.delegate = new QueryableIndexCursorFactory(index,
timeBoundaryInspector);
this.bundleAcquirer = bundleAcquirer;
+ this.servableBundles = servableBundles;
}
@Override
@@ -98,7 +119,18 @@ public class PartialQueryableIndexCursorFactory implements
CursorFactory
+ "on-demand loading, or acquire the segment via the eager path so
all files are loaded up front."
);
}
- return delegate.makeCursorHolder(spec);
+ if (servableBundles == null) {
+ return delegate.makeCursorHolder(spec);
+ }
+ // restricted: dispatch from the restricted plan rather than letting the
delegate match over the whole segment
+ final PartialQueryableIndex.CursorPrefetchPlan plan =
index.planCursorPrefetch(spec, servableBundles);
+ if (plan.bundles().isEmpty()) {
+ return EmptyCursorHolder.forSpec(spec);
+ }
+ if (plan.clusterGroupPlan() != null) {
+ return delegate.makeClusteredCursorHolder(spec, plan.clusterGroupPlan());
+ }
+ return delegate.makeCursorHolderForProjection(spec,
plan.matchedProjection());
}
@Override
@@ -106,12 +138,13 @@ public class PartialQueryableIndexCursorFactory
implements CursorFactory
{
// The index resolves what the spec needs (projection match / surviving
cluster groups / base table, required
// columns, planned range reads); this factory purely orchestrates the
downloads and cursor wiring.
- final PartialQueryableIndex.CursorPrefetchPlan plan =
index.planCursorPrefetch(spec);
+ final PartialQueryableIndex.CursorPrefetchPlan plan =
index.planCursorPrefetch(spec, servableBundles);
+ if (plan.bundles().isEmpty()) {
+ // Filter rules out every cluster group, or nothing servable can answer
the spec: no bundle to acquire, nothing
+ // to download.
+ return AsyncCursorHolder.completed(EmptyCursorHolder.forSpec(spec));
+ }
if (plan.clusterGroupPlan() != null) {
- if (plan.bundles().isEmpty()) {
- // Filter rules out every cluster group: no bundle to acquire, nothing
to download.
- return AsyncCursorHolder.completed(EmptyCursorHolder.forSpec(spec));
- }
// reuse the plan's cluster resolution
return buildAsyncCursorHolder(
plan.bundles(),
diff --git
a/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndexSegment.java
b/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndexSegment.java
index 44856c52385..9414f9d7fcd 100644
---
a/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndexSegment.java
+++
b/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndexSegment.java
@@ -26,6 +26,7 @@ import org.joda.time.Interval;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.Closeable;
+import java.util.function.Predicate;
/**
* {@link Segment} wrapper around a {@link PartialQueryableIndex}. Mirrors
{@link QueryableIndexSegment} but wires up
@@ -52,6 +53,23 @@ public class PartialQueryableIndexSegment implements
ReferenceCountedSegmentProv
Closeable onClose,
PartialBundleAcquirer bundleAcquirer
)
+ {
+ this(index, segmentId, onClose, bundleAcquirer, null);
+ }
+
+ /**
+ * @param servableBundles when non-null, the cursor factory only serves the
bundles this accepts, as if they were the
+ * whole segment (see {@link
PartialQueryableIndexCursorFactory}). Other {@link #as} views,
+ * such as {@link QueryableIndex}, {@link Metadata},
and {@link RowCountInspector}, still
+ * describe the whole segment.
+ */
+ public PartialQueryableIndexSegment(
+ PartialQueryableIndex index,
+ SegmentId segmentId,
+ Closeable onClose,
+ PartialBundleAcquirer bundleAcquirer,
+ @Nullable Predicate<String> servableBundles
+ )
{
this.index = index;
this.timeBoundaryInspector = V10TimeBoundaryInspector.forBaseProjection(
@@ -61,7 +79,8 @@ public class PartialQueryableIndexSegment implements
ReferenceCountedSegmentProv
this.cursorFactory = new PartialQueryableIndexCursorFactory(
index,
timeBoundaryInspector,
- bundleAcquirer
+ bundleAcquirer,
+ servableBundles
);
this.segmentId = segmentId;
this.onClose = onClose;
diff --git
a/processing/src/main/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10.java
b/processing/src/main/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10.java
index d5a372d635f..5bf6992cc83 100644
---
a/processing/src/main/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10.java
+++
b/processing/src/main/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10.java
@@ -412,6 +412,27 @@ public class PartialSegmentFileMapperV10 implements
SegmentFileMapper
return bundleToContainerIndices.keySet();
}
+ /**
+ * The bundle that physically holds {@code requestedBundleName}, across this
mapper and its externals. Normally the
+ * name itself, but when no container carries that name and {@link
SegmentFileBuilder#ROOT_BUNDLE_NAME} is the
+ * segment's only bundle, the root bundle: legacy V10 segments written
before bundle names were recorded keep
+ * everything there. Any other unknown name is returned unchanged, so
callers fail loudly rather than silently reading
+ * the wrong data.
+ */
+ public String resolveBundleName(String requestedBundleName)
+ {
+ final Set<String> present = new HashSet<>(getBundleNames());
+ for (PartialSegmentFileMapperV10 external : externalMappers.values()) {
+ present.addAll(external.getBundleNames());
+ }
+ if (!present.contains(requestedBundleName)
+ && present.size() == 1
+ && present.contains(SegmentFileBuilder.ROOT_BUNDLE_NAME)) {
+ return SegmentFileBuilder.ROOT_BUNDLE_NAME;
+ }
+ return requestedBundleName;
+ }
+
/**
* Indices, into this mapper's {@link SegmentFileMetadata#getContainers()}
list, of the containers belonging to
* {@code bundleName}; empty if this mapper has no containers in that bundle.
@@ -663,9 +684,15 @@ public class PartialSegmentFileMapperV10 implements
SegmentFileMapper
/**
* Plan the parallel-capped range reads for every not-yet-resident file in
{@code bundleName}'s containers, across
* this mapper AND every attached external mapper so runs come back paired
with their owning mapper, unlike the
- * self-scoped {@link #planParallelFetch}. Empty for an unknown bundle.
+ * self-scoped {@link #planParallelFetch}. The name is first resolved by
{@link #resolveBundleName}, so on a legacy
+ * root-only segment any bundle name plans the root bundle. Empty for an
unknown bundle.
*/
public List<PlannedFetch> planParallelFetchBundle(String bundleName)
+ {
+ return planParallelFetchResolvedBundle(resolveBundleName(bundleName));
+ }
+
+ private List<PlannedFetch> planParallelFetchResolvedBundle(String bundleName)
{
checkClosed();
final List<PlannedFetch> fetches = new ArrayList<>();
@@ -690,7 +717,8 @@ public class PartialSegmentFileMapperV10 implements
SegmentFileMapper
}
}
for (PartialSegmentFileMapperV10 external : externalMappers.values()) {
- fetches.addAll(external.planParallelFetchBundle(bundleName));
+ // already resolved against the whole segment, so don't re-resolve
against the external's bundles alone
+ fetches.addAll(external.planParallelFetchResolvedBundle(bundleName));
}
return fetches;
}
diff --git
a/processing/src/main/java/org/apache/druid/segment/loading/AcquireMode.java
b/processing/src/main/java/org/apache/druid/segment/loading/AcquireMode.java
index 60c0199a671..0d4adc16d49 100644
--- a/processing/src/main/java/org/apache/druid/segment/loading/AcquireMode.java
+++ b/processing/src/main/java/org/apache/druid/segment/loading/AcquireMode.java
@@ -45,5 +45,22 @@ public enum AcquireMode
* Degrades to {@link #FULL} behavior when the segment's deep-storage layout
cannot supply range reads (e.g. a
* non-V10 or zipped segment) or when partial downloads are disabled by
configuration.
*/
- PARTIAL
+ PARTIAL,
+
+ /**
+ * Testing/experimental. Like {@link #PARTIAL}, but the returned segment
only serves the bundles selected by the
+ * segment's current partial-load rule, as if those bundles were the whole
segment. Selected bundles that are not yet
+ * resident are still fetched on demand at cursor-build, so callers must use
the async API such as
+ * {@link org.apache.druid.segment.CursorFactory#makeCursorHolderAsync}.
+ * <p>
+ * Projection matching only considers rule-selected projections, so a query
falls back to another selected
+ * projection, a selected base table, or the selected cluster groups.
Unselected cluster groups are left out of the
+ * results, and a query whose projection or base table is not selected reads
no rows. A matched projection may still
+ * read the base table columns it depends on, since it cannot be read
without them.
+ * <p>
+ * Never degrades to {@link #FULL}: a segment with no partial-load rule
applied (including segments that cannot be
+ * partially loaded, or any segment when partial downloads are disabled)
serves no rows, and nothing is downloaded for
+ * it. The bundle selection is snapshotted when the segment is acquired.
+ */
+ PARTIAL_RULE_HELD
}
diff --git
a/processing/src/test/java/org/apache/druid/query/QueryContextTest.java
b/processing/src/test/java/org/apache/druid/query/QueryContextTest.java
index 8ac2eb6c990..30d9a447693 100644
--- a/processing/src/test/java/org/apache/druid/query/QueryContextTest.java
+++ b/processing/src/test/java/org/apache/druid/query/QueryContextTest.java
@@ -599,6 +599,13 @@ public class QueryContextTest
QueryContext.builder().putRaw(QueryContextParameters.SEGMENT_ACQUIRE_MODE.getName(),
"PARTIAL").toContext()
.getSegmentAcquireMode(AcquireMode.FULL)
);
+ assertEquals(
+ AcquireMode.PARTIAL_RULE_HELD,
+ QueryContext.builder()
+
.putRaw(QueryContextParameters.SEGMENT_ACQUIRE_MODE.getName(),
"partial_rule_held")
+ .toContext()
+ .getSegmentAcquireMode(AcquireMode.FULL)
+ );
assertThrows(
BadQueryContextException.class,
() ->
QueryContext.builder().putRaw(QueryContextParameters.SEGMENT_ACQUIRE_MODE.getName(),
"dummy").toContext()
diff --git
a/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexCursorFactoryClusteredProjectionTest.java
b/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexCursorFactoryClusteredProjectionTest.java
index 7a689999b87..5f0c198c870 100644
---
a/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexCursorFactoryClusteredProjectionTest.java
+++
b/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexCursorFactoryClusteredProjectionTest.java
@@ -44,6 +44,7 @@ import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
+import javax.annotation.Nullable;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
@@ -52,6 +53,7 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
+import java.util.function.Predicate;
/**
* Partial (on-demand) download coverage for a segment that is <em>both</em>
clustered <em>and</em> carries an aggregate
@@ -191,12 +193,87 @@ class
PartialQueryableIndexCursorFactoryClusteredProjectionTest extends PartialQ
}
}
+ @Test
+ void testUnservedProjectionFallsBackToServedGroups() throws IOException
+ {
+ final CountingRangeReader rangeReader = new
CountingRangeReader(segmentDir);
+ try (IndexAndMapper opened = openIndex(rangeReader,
"combo_unserved_projection")) {
+ final PartialQueryableIndexCursorFactory factory =
factory(opened.index(), ACME_BUNDLE::equals);
+ // this spec matches the projection when it's servable (see
testProjectionMatchDownloadsOnlyProjectionBundle)
+ final CursorBuildSpec aggSpec = CursorBuildSpec.builder()
+
.setGroupingColumns(List.of("tenant"))
+
.setAggregators(List.of(new LongSumAggregatorFactory("sum_x", "x")))
+
.setPhysicalColumns(Set.of("tenant", "x"))
+ .build();
+
+ try (AsyncCursorHolder asyncHolder =
factory.makeCursorHolderAsync(aggSpec);
+ CursorHolder holder = asyncHolder.release()) {
+ // only the served acme group answers (2 of the 3 rows)
+ final Cursor cursor = holder.asCursor();
+ int rows = 0;
+ while (!cursor.isDone()) {
+ rows++;
+ cursor.advance();
+ }
+ Assertions.assertEquals(2, rows);
+ final Set<String> downloaded = opened.mapper().getDownloadedFiles();
+ Assertions.assertTrue(downloaded.contains(ACME_BUNDLE + "/x"), "got: "
+ downloaded);
+ Assertions.assertTrue(
+ downloaded.stream().noneMatch(f -> f.startsWith(PROJECTION_BUNDLE
+ "/")
+ || f.startsWith(GLOBEX_BUNDLE +
"/")),
+ "unserved bundles must not download; got: " + downloaded
+ );
+ }
+ }
+ }
+
+ @Test
+ void testServedProjectionWithUnservedGroups() throws IOException
+ {
+ final CountingRangeReader rangeReader = new
CountingRangeReader(segmentDir);
+ try (IndexAndMapper opened = openIndex(rangeReader,
"combo_served_projection")) {
+ final PartialQueryableIndexCursorFactory factory =
factory(opened.index(), PROJECTION_BUNDLE::equals);
+ final CursorBuildSpec aggSpec = CursorBuildSpec.builder()
+
.setGroupingColumns(List.of("tenant"))
+
.setAggregators(List.of(new LongSumAggregatorFactory("sum_x", "x")))
+
.setPhysicalColumns(Set.of("tenant", "x"))
+ .build();
+ try (AsyncCursorHolder asyncHolder =
factory.makeCursorHolderAsync(aggSpec);
+ CursorHolder holder = asyncHolder.release()) {
+ Assertions.assertNotNull(holder.asCursor(), "the served projection
answers");
+ }
+
+ // a spec the projection can't answer would need the unserved groups, so
it reads nothing
+ final CursorBuildSpec spec = CursorBuildSpec.builder()
+ .setFilter(new
EqualityFilter("tenant", ColumnType.STRING, "acme", null))
+ .build();
+ try (AsyncCursorHolder asyncHolder = factory.makeCursorHolderAsync(spec);
+ CursorHolder holder = asyncHolder.release()) {
+ Assertions.assertTrue(scanRegion(holder).isEmpty());
+ }
+ final Set<String> downloaded = opened.mapper().getDownloadedFiles();
+ Assertions.assertTrue(
+ downloaded.stream().noneMatch(f -> f.startsWith(ACME_BUNDLE + "/")
|| f.startsWith(GLOBEX_BUNDLE + "/")),
+ "unserved groups must not download; got: " + downloaded
+ );
+ }
+ }
+
private PartialQueryableIndexCursorFactory factory(PartialQueryableIndex
index)
+ {
+ return factory(index, null);
+ }
+
+ private PartialQueryableIndexCursorFactory factory(
+ PartialQueryableIndex index,
+ @Nullable Predicate<String> servableBundles
+ )
{
return new PartialQueryableIndexCursorFactory(
index,
QueryableIndexTimeBoundaryInspector.create(index),
- noOpAcquirer(directExec())
+ noOpAcquirer(directExec()),
+ servableBundles
);
}
diff --git
a/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexCursorFactoryClusteredTest.java
b/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexCursorFactoryClusteredTest.java
index af4ea18a9ce..87a71f9c9eb 100644
---
a/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexCursorFactoryClusteredTest.java
+++
b/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexCursorFactoryClusteredTest.java
@@ -249,6 +249,65 @@ class PartialQueryableIndexCursorFactoryClusteredTest
extends PartialQueryableIn
}
}
+ @Test
+ void testServableBundlesDropsUnservedGroups() throws IOException
+ {
+ final CountingRangeReader rangeReader = new
CountingRangeReader(segmentDir);
+ try (IndexAndMapper opened = openIndex(rangeReader, "servable_acme")) {
+ final PartialSegmentFileMapperV10 mapper = opened.mapper();
+ final PartialQueryableIndexCursorFactory factory = new
PartialQueryableIndexCursorFactory(
+ opened.index(),
+ QueryableIndexTimeBoundaryInspector.create(opened.index()),
+ noOpAcquirer(directExec()),
+ ACME_BUNDLE::equals
+ );
+
+ try (AsyncCursorHolder asyncHolder =
factory.makeCursorHolderAsync(CursorBuildSpec.FULL_SCAN);
+ CursorHolder holder = asyncHolder.release()) {
+ // the unserved globex group is left out entirely, rather than nil-ed
+ Assertions.assertEquals(
+ List.of(List.of("acme", "us-east-1"), List.of("acme",
"us-west-2")),
+ scanTenantRegion(holder)
+ );
+ final Set<String> downloaded = mapper.getDownloadedFiles();
+ Assertions.assertTrue(
+ downloaded.stream().noneMatch(f -> f.startsWith(GLOBEX_BUNDLE +
"/")),
+ "unserved globex group must not be downloaded; got: " + downloaded
+ );
+ }
+
+ // sync path drops the unserved group too, even once it is resident
+ mapper.fetchFiles(mapper.getSegmentFileMetadata().getFiles().keySet());
+ try (CursorHolder holder =
factory.makeCursorHolder(CursorBuildSpec.FULL_SCAN)) {
+ Assertions.assertEquals(
+ List.of(List.of("acme", "us-east-1"), List.of("acme",
"us-west-2")),
+ scanTenantRegion(holder)
+ );
+ }
+ }
+ }
+
+ @Test
+ void testNoServableGroupsDownloadsNothing() throws IOException
+ {
+ final CountingRangeReader rangeReader = new
CountingRangeReader(segmentDir);
+ try (IndexAndMapper opened = openIndex(rangeReader, "servable_none")) {
+ final PartialQueryableIndexCursorFactory factory = new
PartialQueryableIndexCursorFactory(
+ opened.index(),
+ QueryableIndexTimeBoundaryInspector.create(opened.index()),
+ noOpAcquirer(directExec()),
+ bundle -> false
+ );
+ rangeReader.resetCount();
+ try (AsyncCursorHolder asyncHolder =
factory.makeCursorHolderAsync(CursorBuildSpec.FULL_SCAN);
+ CursorHolder holder = asyncHolder.release()) {
+ Assertions.assertTrue(scanTenantRegion(holder).isEmpty());
+ }
+ Assertions.assertEquals(0, rangeReader.getReadCount());
+ Assertions.assertTrue(opened.mapper().getDownloadedFiles().isEmpty());
+ }
+ }
+
@Test
void testSyncMakeCursorHolderAfterFullDownload() throws IOException
{
diff --git
a/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexServableBundlesTest.java
b/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexServableBundlesTest.java
new file mode 100644
index 00000000000..dd9cbb110a2
--- /dev/null
+++
b/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexServableBundlesTest.java
@@ -0,0 +1,353 @@
+/*
+ * 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;
+
+import com.google.common.util.concurrent.ListeningExecutorService;
+import org.apache.druid.common.asyncresource.AsyncResource;
+import org.apache.druid.common.asyncresource.AsyncResources;
+import org.apache.druid.data.input.InputRow;
+import org.apache.druid.data.input.ListBasedInputRow;
+import org.apache.druid.data.input.impl.AggregateProjectionSpec;
+import org.apache.druid.data.input.impl.DimensionsSpec;
+import org.apache.druid.data.input.impl.LongDimensionSchema;
+import org.apache.druid.data.input.impl.StringDimensionSchema;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.java.util.common.DateTimes;
+import org.apache.druid.query.QueryContext;
+import org.apache.druid.query.QueryContexts;
+import org.apache.druid.query.aggregation.CountAggregatorFactory;
+import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.segment.column.RowSignature;
+import org.apache.druid.segment.data.CompressionStrategy;
+import org.apache.druid.segment.file.CountingRangeReader;
+import org.apache.druid.segment.file.PartialSegmentFileMapperV10;
+import org.apache.druid.segment.incremental.IncrementalIndexSchema;
+import org.apache.druid.segment.projections.Projections;
+import
org.apache.druid.segment.writeout.OffHeapMemorySegmentWriteOutMediumFactory;
+import org.apache.druid.testing.TemporaryFolderExtension;
+import org.joda.time.DateTime;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+/**
+ * Coverage for planning and serving a {@link PartialQueryableIndex}
restricted to a set of servable bundles (what
+ * {@link org.apache.druid.segment.loading.AcquireMode#PARTIAL_RULE_HELD}
uses), over a plain (non-clustered) segment
+ * with two aggregate projections that can both answer the same query. The
clustered cases live in
+ * {@link PartialQueryableIndexCursorFactoryClusteredTest} and
+ * {@link PartialQueryableIndexCursorFactoryClusteredProjectionTest}.
+ */
+class PartialQueryableIndexServableBundlesTest extends
PartialQueryableIndexCursorFactoryTestBase
+{
+ private static final DateTime TIME = DateTimes.of("2025-01-01");
+ private static final String BASE = Projections.BASE_TABLE_PROJECTION_NAME;
+ // 2 rows (one per dim1 value), so it sorts ahead of BIG_PROJECTION when
both match
+ private static final String SMALL_PROJECTION = "dim1_count";
+ // 4 rows (one per dim1, metric1 pair)
+ private static final String BIG_PROJECTION = "dim1_metric1_count";
+
+ private static final RowSignature ROW_SIGNATURE = RowSignature.builder()
+ .add("dim1",
ColumnType.STRING)
+
.add("metric1", ColumnType.LONG)
+ .build();
+
+ private static final List<AggregateProjectionSpec> PROJECTIONS = List.of(
+ AggregateProjectionSpec.builder(SMALL_PROJECTION)
+ .groupingColumns(new
StringDimensionSchema("dim1"))
+ .aggregators(new CountAggregatorFactory("_count"))
+ .build(),
+ AggregateProjectionSpec.builder(BIG_PROJECTION)
+ .groupingColumns(new
StringDimensionSchema("dim1"), new LongDimensionSchema("metric1"))
+ .aggregators(new CountAggregatorFactory("_count"))
+ .build()
+ );
+
+ private static final List<InputRow> ROWS = Arrays.asList(
+ new ListBasedInputRow(ROW_SIGNATURE, TIME,
ROW_SIGNATURE.getColumnNames(), Arrays.asList("a", 1L)),
+ new ListBasedInputRow(ROW_SIGNATURE, TIME.plusMinutes(1),
ROW_SIGNATURE.getColumnNames(), Arrays.asList("a", 2L)),
+ new ListBasedInputRow(ROW_SIGNATURE, TIME.plusMinutes(2),
ROW_SIGNATURE.getColumnNames(), Arrays.asList("b", 3L)),
+ new ListBasedInputRow(ROW_SIGNATURE, TIME.plusMinutes(3),
ROW_SIGNATURE.getColumnNames(), Arrays.asList("b", 4L))
+ );
+
+ // group by dim1 + count, which both projections can answer
+ private static final CursorBuildSpec COUNT_BY_DIM1 =
+ CursorBuildSpec.builder()
+ .setGroupingColumns(List.of("dim1"))
+ .setAggregators(List.of(new
CountAggregatorFactory("_count")))
+ .setPhysicalColumns(Set.of("dim1"))
+ .build();
+
+ @RegisterExtension
+ public static final TemporaryFolderExtension SHARED_TEMPORARY_FOLDER =
TemporaryFolderExtension.classScoped();
+
+ private static File segmentDir;
+
+ @BeforeAll
+ static void buildSegment() throws IOException
+ {
+ final File tmpDir = SHARED_TEMPORARY_FOLDER.newFolder("build_" +
ThreadLocalRandom.current().nextInt());
+ segmentDir = IndexBuilder.create()
+ .useV10()
+ .tmpDir(tmpDir)
+
.segmentWriteOutMediumFactory(OffHeapMemorySegmentWriteOutMediumFactory.instance())
+ .schema(
+ IncrementalIndexSchema.builder()
+ .withDimensionsSpec(
+
DimensionsSpec.builder()
+
.setDimensions(
+
List.of(
+
new StringDimensionSchema("dim1"),
+
new LongDimensionSchema("metric1")
+ )
+ )
+
.build()
+ )
+ .withRollup(false)
+
.withMinTimestamp(TIME.getMillis())
+
.withProjections(PROJECTIONS)
+ .build()
+ )
+
.indexSpec(IndexSpec.builder().withMetadataCompression(CompressionStrategy.NONE).build())
+ .rows(ROWS)
+ .buildMMappedIndexFile();
+ }
+
+ @Test
+ void testUnrestrictedMatchesSmallestProjection() throws IOException
+ {
+ try (IndexAndMapper opened = openIndex(new
CountingRangeReader(segmentDir), "unrestricted")) {
+ final PartialQueryableIndex.CursorPrefetchPlan plan =
opened.index().planCursorPrefetch(COUNT_BY_DIM1, null);
+ Assertions.assertEquals(SMALL_PROJECTION,
plan.matchedProjection().getName());
+ }
+ }
+
+ @Test
+ void testUnservedBestProjectionFallsBackToServedProjection() throws
IOException
+ {
+ final CountingRangeReader rangeReader = new
CountingRangeReader(segmentDir);
+ try (IndexAndMapper opened = openIndex(rangeReader,
"fallback_projection")) {
+ final PartialQueryableIndex.CursorPrefetchPlan plan =
+ opened.index().planCursorPrefetch(COUNT_BY_DIM1,
BIG_PROJECTION::equals);
+ Assertions.assertEquals(BIG_PROJECTION,
plan.matchedProjection().getName());
+ // the base parent of the dim1 projection column is a dependency of the
projection, so it's planned even though
+ // the base table itself is not servable
+ Assertions.assertEquals(List.of(BIG_PROJECTION, BASE),
bundleNames(plan));
+
+ final RecordingAcquirer acquirer = new RecordingAcquirer(directExec());
+ final PartialQueryableIndexCursorFactory factory =
factory(opened.index(), acquirer, BIG_PROJECTION::equals);
+ try (AsyncCursorHolder asyncHolder =
factory.makeCursorHolderAsync(COUNT_BY_DIM1);
+ CursorHolder holder = asyncHolder.release()) {
+ Assertions.assertEquals(4, countRows(holder), "the big projection has
one row per (dim1, metric1)");
+ }
+ Assertions.assertEquals(new TreeSet<>(Set.of(BIG_PROJECTION, BASE)),
acquirer.acquired);
+ final Set<String> downloaded = opened.mapper().getDownloadedFiles();
+ Assertions.assertTrue(
+ downloaded.stream().noneMatch(f -> f.startsWith(SMALL_PROJECTION +
"/")),
+ "the unserved projection must not download; got: " + downloaded
+ );
+ }
+ }
+
+ @Test
+ void testNoServedProjectionFallsBackToServedBaseTable() throws IOException
+ {
+ final CountingRangeReader rangeReader = new
CountingRangeReader(segmentDir);
+ try (IndexAndMapper opened = openIndex(rangeReader, "fallback_base")) {
+ final RecordingAcquirer acquirer = new RecordingAcquirer(directExec());
+ final PartialQueryableIndexCursorFactory factory =
factory(opened.index(), acquirer, BASE::equals);
+ try (AsyncCursorHolder asyncHolder =
factory.makeCursorHolderAsync(COUNT_BY_DIM1);
+ CursorHolder holder = asyncHolder.release()) {
+ Assertions.assertEquals(4, countRows(holder), "the base table has
every ingested row");
+ }
+ Assertions.assertEquals(new TreeSet<>(Set.of(BASE)), acquirer.acquired);
+ final Set<String> downloaded = opened.mapper().getDownloadedFiles();
+ Assertions.assertTrue(
+ downloaded.stream().allMatch(f -> f.startsWith(BASE + "/")),
+ "only base table files may download; got: " + downloaded
+ );
+ }
+ }
+
+ @Test
+ void testUnservedBaseTableReadsNothing() throws IOException
+ {
+ final CountingRangeReader rangeReader = new
CountingRangeReader(segmentDir);
+ try (IndexAndMapper opened = openIndex(rangeReader, "unserved_base")) {
+ // a projection is servable, but a non-aggregate scan can only be
answered by the base table, which is not
+ Assertions.assertTrue(
+ opened.index().planCursorPrefetch(CursorBuildSpec.FULL_SCAN,
SMALL_PROJECTION::equals).bundles().isEmpty()
+ );
+ final RecordingAcquirer acquirer = new RecordingAcquirer(directExec());
+ final PartialQueryableIndexCursorFactory factory =
factory(opened.index(), acquirer, SMALL_PROJECTION::equals);
+ rangeReader.resetCount();
+ try (AsyncCursorHolder asyncHolder =
factory.makeCursorHolderAsync(CursorBuildSpec.FULL_SCAN)) {
+ Assertions.assertTrue(asyncHolder.isReady(), "nothing to download, so
the holder is ready immediately");
+ try (CursorHolder holder = asyncHolder.release()) {
+ Assertions.assertEquals(0, countRows(holder));
+ }
+ }
+ Assertions.assertTrue(acquirer.acquired.isEmpty(), "got: " +
acquirer.acquired);
+ Assertions.assertEquals(0, rangeReader.getReadCount());
+ Assertions.assertTrue(opened.mapper().getDownloadedFiles().isEmpty());
+ }
+ }
+
+ @Test
+ void testNothingServableReadsNothing() throws IOException
+ {
+ final CountingRangeReader rangeReader = new
CountingRangeReader(segmentDir);
+ try (IndexAndMapper opened = openIndex(rangeReader, "nothing_servable")) {
+ final RecordingAcquirer acquirer = new RecordingAcquirer(directExec());
+ final PartialQueryableIndexCursorFactory factory =
factory(opened.index(), acquirer, bundle -> false);
+ rangeReader.resetCount();
+ for (CursorBuildSpec spec : List.of(COUNT_BY_DIM1,
CursorBuildSpec.FULL_SCAN)) {
+ try (AsyncCursorHolder asyncHolder =
factory.makeCursorHolderAsync(spec);
+ CursorHolder holder = asyncHolder.release()) {
+ Assertions.assertEquals(0, countRows(holder));
+ }
+ }
+ Assertions.assertTrue(acquirer.acquired.isEmpty(), "got: " +
acquirer.acquired);
+ Assertions.assertEquals(0, rangeReader.getReadCount());
+ }
+ }
+
+ @Test
+ void testSyncPathHonorsServableBundles() throws IOException
+ {
+ final CountingRangeReader rangeReader = new
CountingRangeReader(segmentDir);
+ try (IndexAndMapper opened = openIndex(rangeReader, "sync_restricted")) {
+ final PartialSegmentFileMapperV10 mapper = opened.mapper();
+ final PartialQueryableIndexCursorFactory factory =
+ factory(opened.index(), new RecordingAcquirer(directExec()),
BIG_PROJECTION::equals);
+
+ // still refuses to download on the sync path
+ Assertions.assertThrows(DruidException.class, () ->
factory.makeCursorHolder(COUNT_BY_DIM1));
+
+ mapper.fetchFiles(mapper.getSegmentFileMetadata().getFiles().keySet());
+ Assertions.assertTrue(mapper.isFullyDownloaded());
+ try (CursorHolder holder = factory.makeCursorHolder(COUNT_BY_DIM1)) {
+ Assertions.assertEquals(4, countRows(holder), "served from the big
projection, not the unserved small one");
+ }
+ try (CursorHolder holder =
factory.makeCursorHolder(CursorBuildSpec.FULL_SCAN)) {
+ Assertions.assertEquals(0, countRows(holder), "the base table is not
servable, even though it is resident");
+ }
+ }
+ }
+
+ @Test
+ void testUseProjectionNamingUnservedProjectionFails() throws IOException
+ {
+ try (IndexAndMapper opened = openIndex(new
CountingRangeReader(segmentDir), "use_projection")) {
+ final CursorBuildSpec spec = CursorBuildSpec.builder(COUNT_BY_DIM1)
+ .setQueryContext(
+ QueryContext.of(
+
Map.of(QueryContexts.USE_PROJECTION, SMALL_PROJECTION)
+ )
+ )
+ .build();
+ Assertions.assertEquals(
+ SMALL_PROJECTION,
+ opened.index().planCursorPrefetch(spec,
null).matchedProjection().getName()
+ );
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> opened.index().planCursorPrefetch(spec, BIG_PROJECTION::equals)
+ );
+ Assertions.assertTrue(e.getMessage().contains("does not satisfy query"),
e.getMessage());
+ }
+ }
+
+ private static PartialQueryableIndexCursorFactory factory(
+ PartialQueryableIndex index,
+ PartialBundleAcquirer acquirer,
+ Predicate<String> servableBundles
+ )
+ {
+ return new PartialQueryableIndexCursorFactory(
+ index,
+ QueryableIndexTimeBoundaryInspector.create(index),
+ acquirer,
+ servableBundles
+ );
+ }
+
+ private static List<String>
bundleNames(PartialQueryableIndex.CursorPrefetchPlan plan)
+ {
+ return plan.bundles()
+ .stream()
+ .map(PartialQueryableIndex.PrefetchBundle::bundleName)
+ .collect(Collectors.toList());
+ }
+
+ private static int countRows(CursorHolder holder)
+ {
+ final Cursor cursor = holder.asCursor();
+ if (cursor == null) {
+ return 0;
+ }
+ int rows = 0;
+ while (!cursor.isDone()) {
+ rows++;
+ cursor.advance();
+ }
+ return rows;
+ }
+
+ /**
+ * Records the bundles the cursor factory acquires; holds are no-ops.
+ */
+ private static final class RecordingAcquirer implements PartialBundleAcquirer
+ {
+ private final ListeningExecutorService downloadExec;
+ private final Set<String> acquired = new TreeSet<>();
+
+ private RecordingAcquirer(ListeningExecutorService downloadExec)
+ {
+ this.downloadExec = downloadExec;
+ }
+
+ @Override
+ public Closeable acquire(String bundleName)
+ {
+ acquired.add(bundleName);
+ return () -> {};
+ }
+
+ @Override
+ public <T> AsyncResource<T> submitDownload(Callable<T> task)
+ {
+ return AsyncResources.fromFutureUnmanaged(downloadExec.submit(task));
+ }
+ }
+}
diff --git
a/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentBundleCacheEntry.java
b/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentBundleCacheEntry.java
index ccf2acab3f9..3614ff6f5d4 100644
---
a/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentBundleCacheEntry.java
+++
b/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentBundleCacheEntry.java
@@ -651,20 +651,29 @@ public class PartialSegmentBundleCacheEntry implements
CacheEntry
* <li>A future writer that legitimately writes some files to the root
bundle <em>alongside</em> named bundles is
* unaffected: its reader asks for {@code __root__} by name, which
exists, so the fallback never fires.</li>
* </ul>
+ * Delegates to {@link PartialSegmentFileMapperV10#resolveBundleName}, which
whole-bundle prefetch planning also
+ * uses, so the cache layer and the planner always agree on the physical
bundle.
*/
public static String resolveBundleName(PartialSegmentFileMapperV10
fileMapper, String requestedBundleName)
{
- if (!findContainersForBundle(fileMapper, requestedBundleName).isEmpty()) {
- return requestedBundleName;
- }
- final Set<String> present = bundleNames(fileMapper);
- // legacy v10 segments from before bundle field was persisted in the
segment
- if (present.size() == 1 &&
present.contains(SegmentFileBuilder.ROOT_BUNDLE_NAME)) {
- return SegmentFileBuilder.ROOT_BUNDLE_NAME;
+ return fileMapper.resolveBundleName(requestedBundleName);
+ }
+
+ /**
+ * {@link #resolveBundleName} applied to each of {@code
requestedBundleNames}, giving the bundles that physically hold
+ * them. On a legacy root-only segment every name resolves to the root
bundle, so a partial-load rule that selects a
+ * projection by name selects the root bundle.
+ */
+ public static Set<String> resolveBundleNames(
+ PartialSegmentFileMapperV10 fileMapper,
+ Collection<String> requestedBundleNames
+ )
+ {
+ final Set<String> resolved = new HashSet<>();
+ for (String requestedBundleName : requestedBundleNames) {
+ resolved.add(resolveBundleName(fileMapper, requestedBundleName));
}
- // Requested bundle is absent and there are named bundles present: leave
the name as-is so the caller fails
- // loudly (forBundle throws "no containers") rather than silently serving
the wrong data.
- return requestedBundleName;
+ return Set.copyOf(resolved);
}
private static void awaitMount(SettableFuture<Void> future) throws
IOException
diff --git
a/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntry.java
b/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntry.java
index fa0f7bda6d3..c30391ad0e2 100644
---
a/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntry.java
+++
b/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntry.java
@@ -63,6 +63,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Predicate;
/**
* Cache entry for the metadata header of a V10 segment loaded via partial
download. Mounting this entry range-reads
@@ -1162,7 +1163,7 @@ public class PartialSegmentMetadataCacheEntry implements
SegmentCacheEntry, Resi
@Override
public Optional<Segment> acquireReference()
{
- return buildSegmentWithExtraClose(null);
+ return buildSegmentWithExtraClose(null, false);
}
/**
@@ -1172,12 +1173,23 @@ public class PartialSegmentMetadataCacheEntry
implements SegmentCacheEntry, Resi
@Override
public Optional<Segment> acquireReference(Closeable extraOnClose)
{
- return buildSegmentWithExtraClose(extraOnClose);
+ return buildSegmentWithExtraClose(extraOnClose, false);
}
- private Optional<Segment> buildSegmentWithExtraClose(@Nullable Closeable
extraOnClose)
+ /**
+ * Like {@link #acquireReference(Closeable)}, but for {@link
AcquireMode#PARTIAL_RULE_HELD}: the returned segment only
+ * serves the bundles selected by the partial-load rule applied when this is
called, and serves no rows if no rule is
+ * applied. Selected bundles that are not resident are still downloaded on
demand by the async cursor factory.
+ */
+ public Optional<Segment> acquireRuleHeldReference(@Nullable Closeable
extraOnClose)
+ {
+ return buildSegmentWithExtraClose(extraOnClose, true);
+ }
+
+ private Optional<Segment> buildSegmentWithExtraClose(@Nullable Closeable
extraOnClose, boolean ruleHeldOnly)
{
final PartialQueryableIndex index;
+ final Predicate<String> servableBundles;
entryLock.lock();
try {
if (fileMapper == null) {
@@ -1192,6 +1204,18 @@ public class PartialSegmentMetadataCacheEntry implements
SegmentCacheEntry, Resi
);
}
index = queryableIndex;
+ if (ruleHeldOnly) {
+ // Snapshot the selection (empty when no rule is applied). It names
physical bundles, since the cache manager
+ // resolves it when applying the rule, while the planner asks by
logical name, so resolve each requested name
+ // the same way the bundle acquirer does: on a legacy root-only
segment every name maps to the root bundle.
+ final Set<String> selected = ruleSelectedBundleNames;
+ final PartialSegmentFileMapperV10 mapper = fileMapper;
+ servableBundles = bundleName -> selected.contains(
+ PartialSegmentBundleCacheEntry.resolveBundleName(mapper,
bundleName)
+ );
+ } else {
+ servableBundles = null;
+ }
}
catch (Throwable t) {
// We took ownership of extraOnClose; a failure building the queryable
index must release it rather than leak
@@ -1233,7 +1257,8 @@ public class PartialSegmentMetadataCacheEntry implements
SegmentCacheEntry, Resi
index,
segmentId,
onClose,
- bundleAcquirer
+ bundleAcquirer,
+ servableBundles
)
);
}
diff --git
a/server/src/main/java/org/apache/druid/segment/loading/SegmentCacheManager.java
b/server/src/main/java/org/apache/druid/segment/loading/SegmentCacheManager.java
index 58f05c099c9..119942576c4 100644
---
a/server/src/main/java/org/apache/druid/segment/loading/SegmentCacheManager.java
+++
b/server/src/main/java/org/apache/druid/segment/loading/SegmentCacheManager.java
@@ -116,7 +116,9 @@ public interface SegmentCacheManager
* {@link AcquireMode#FULL} a cached entry is returned only if it is
behaviorally fully-materialized. With
* {@link AcquireMode#PARTIAL} a mounted (but possibly
not-yet-fully-downloaded) entry is sufficient, and callers
* acknowledge that synchronous methods may refuse if the segment is not
fully downloaded; they must use asynchronous
- * methods like {@link CursorFactory#makeCursorHolderAsync} instead. See
{@link AcquireMode} for details.
+ * methods like {@link CursorFactory#makeCursorHolderAsync} instead. {@link
AcquireMode#PARTIAL_RULE_HELD} is like
+ * {@link AcquireMode#PARTIAL}, but only returns an entry that a
partial-load rule could apply to, and the returned
+ * segment only serves the rule-selected bundles. See {@link AcquireMode}
for details.
*/
Optional<Segment> acquireCachedSegment(SegmentId segmentId, AcquireMode
acquireMode);
@@ -134,7 +136,9 @@ public interface SegmentCacheManager
* {@link AcquireMode#FULL} the segment is fully materialized up front. With
{@link AcquireMode#PARTIAL}, when the
* segment's {@link LoadSpec} supports range reads, only a minimal amount of
metadata is downloaded and additional
* loading is deferred to the async methods callers use to interact with the
returned segment; partial-ineligible
- * segments fall back to {@link AcquireMode#FULL} behavior. See {@link
AcquireMode} for details.
+ * segments fall back to {@link AcquireMode#FULL} behavior. With {@link
AcquireMode#PARTIAL_RULE_HELD} nothing is
+ * loaded up front: the segment serves only the bundles selected by its
partial-load rule, and if there is no rule it
+ * serves no rows. See {@link AcquireMode} for details.
*/
AcquireSegmentAction acquireSegment(DataSegment dataSegment, AcquireMode
acquireMode);
diff --git
a/server/src/main/java/org/apache/druid/segment/loading/SegmentLocalCacheManager.java
b/server/src/main/java/org/apache/druid/segment/loading/SegmentLocalCacheManager.java
index a310672439c..95bb7991711 100644
---
a/server/src/main/java/org/apache/druid/segment/loading/SegmentLocalCacheManager.java
+++
b/server/src/main/java/org/apache/druid/segment/loading/SegmentLocalCacheManager.java
@@ -462,16 +462,20 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
@Override
public Optional<Segment> acquireCachedSegment(final SegmentId segmentId,
final AcquireMode acquireMode)
{
- // PARTIAL accepts a mounted-but-not-fully-downloaded entry; FULL (and
PARTIAL when partial downloads are disabled)
- // requires a behaviorally fully-materialized entry.
- final boolean requireFullyDownloaded =
- acquireMode == AcquireMode.FULL ||
!config.isVirtualStoragePartialDownloadsEnabled();
- return acquireCachedInternal(segmentId, requireFullyDownloaded);
+ return acquireCachedInternal(segmentId, acquireMode);
}
@Override
public AcquireSegmentAction acquireSegment(final DataSegment dataSegment,
final AcquireMode acquireMode)
{
+ if (acquireMode == AcquireMode.PARTIAL_RULE_HELD) {
+ // Only an existing partial entry can have a rule applied, so never
reserve, mount, or download anything here.
+ // Anything else gets an empty placeholder that serves no rows.
+ final Segment segment = acquireCachedInternal(dataSegment.getId(),
acquireMode).orElseGet(
+ () -> new VirtualPlaceholderSegment(dataSegment)
+ );
+ return
AcquireSegmentAction.completed(AcquireSegmentResult.of(Optional.of(segment)));
+ }
// Partial-eligible segments route through the partial machinery: PARTIAL
mounts lazily (header only, bundles load
// on demand), FULL force-downloads everything up front.
Partial-ineligible segments fall through to the eager path
// below for both modes.
@@ -540,20 +544,25 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
/**
* Shared implementation for {@link #acquireCachedSegment}. Walks the
storage locations checking for existing entries.
* <p>
- * When {@code requireFullyDownloaded} is {@code true} the entry must also
report
- * {@link SegmentCacheEntry#isFullyDownloaded()} ({@link AcquireMode#FULL}),
which never hands back an entry that
- * isn't behaviorally fully-materialized. When {@code false} a mounted entry
is sufficient
- * ({@link AcquireMode#PARTIAL}'s lazy-mount contract), even if some bundles
are still not yet on disk. In either case
- * {@link SegmentCacheEntry#acquireReference(Closeable)} composes the
weak-entry hold into the returned segment's close
- * lifecycle.
+ * {@link AcquireMode#FULL} (and {@link AcquireMode#PARTIAL} when partial
downloads are disabled) requires the entry to
+ * also report {@link SegmentCacheEntry#isFullyDownloaded()}, which never
hands back an entry that isn't behaviorally
+ * fully-materialized. Otherwise a mounted entry is sufficient ({@link
AcquireMode#PARTIAL}'s lazy-mount contract),
+ * even if some bundles are still not yet on disk. In either case {@link
SegmentCacheEntry#acquireReference(Closeable)}
+ * composes the weak-entry hold into the returned segment's close lifecycle.
+ * <p>
+ * {@link AcquireMode#PARTIAL_RULE_HELD} only accepts a mounted {@link
PartialSegmentMetadataCacheEntry}, since no
+ * other entry can have a partial-load rule applied, and returns empty for
anything else.
*/
- private Optional<Segment> acquireCachedInternal(SegmentId segmentId, boolean
requireFullyDownloaded)
+ private Optional<Segment> acquireCachedInternal(SegmentId segmentId,
AcquireMode acquireMode)
{
+ final boolean ruleHeldOnly = acquireMode == AcquireMode.PARTIAL_RULE_HELD;
+ final boolean requireFullyDownloaded =
+ acquireMode == AcquireMode.FULL ||
!config.isVirtualStoragePartialDownloadsEnabled();
final SegmentCacheEntryIdentifier id = new
SegmentCacheEntryIdentifier(segmentId);
for (StorageLocation location : locations) {
final SegmentCacheEntry staticEntry = location.getStaticCacheEntry(id);
if (staticEntry != null) {
- if (staticEntry.isMounted() && (!requireFullyDownloaded ||
staticEntry.isFullyDownloaded())) {
+ if (!ruleHeldOnly && staticEntry.isMounted() &&
(!requireFullyDownloaded || staticEntry.isFullyDownloaded())) {
return staticEntry.acquireReference();
}
return Optional.empty();
@@ -564,6 +573,13 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
final StorageLocation.ReservationHold<SegmentCacheEntry> hold =
location.addWeakReservationHoldIfExists(id);
if (hold != null) {
final SegmentCacheEntry entry = hold.getEntry();
+ if (ruleHeldOnly) {
+ if (entry instanceof PartialSegmentMetadataCacheEntry partial &&
partial.isMounted()) {
+ return partial.acquireRuleHeldReference(hold);
+ }
+ hold.close();
+ return Optional.empty();
+ }
if (entry.isMounted() && (!requireFullyDownloaded ||
entry.isFullyDownloaded())) {
if (requireFullyDownloaded && entry instanceof
PartialSegmentMetadataCacheEntry partial) {
// A FULL reference on a partial segment must pin every bundle for
the segment's lifetime (the sync cursor
@@ -1059,7 +1075,10 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
// awaitEagerDownloadsOrClearRule leaves the metadata entry
weak-reserved with the hook attached; when
// cache eventually reclaims, the info file gets deleted along with
the entry.
metadata.setOnUnmount(() -> deleteSegmentInfoFile(dataSegment));
- final Set<String> selected = Set.copyOf(
+ // Resolve the selection to the bundles that physically hold it
(they differ on legacy root-only segments), so
+ // rule holds, eager downloads, and AcquireMode.PARTIAL_RULE_HELD
all work with real bundle names.
+ final Set<String> selected =
PartialSegmentBundleCacheEntry.resolveBundleNames(
+ mapper,
wrapper.getSelectedBundleNames(dataSegment,
mapper.getSegmentFileMetadata())
);
// Snapshot the rule this call is about to replace, then pin the
UNION of it and the new selection for the
@@ -1804,7 +1823,9 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
"Bootstrap-restored partial metadata for segment[%s] has no file
mapper", dataSegment.getId()
);
}
- final Set<String> selected = Set.copyOf(
+ // resolved to physical bundles, as in loadPartial
+ final Set<String> selected =
PartialSegmentBundleCacheEntry.resolveBundleNames(
+ mapper,
wrapper.getSelectedBundleNames(dataSegment,
mapper.getSegmentFileMetadata())
);
final String priorFingerprint = partial.getRuleFingerprint();
diff --git
a/server/src/main/java/org/apache/druid/segment/loading/VirtualPlaceholderSegment.java
b/server/src/main/java/org/apache/druid/segment/loading/VirtualPlaceholderSegment.java
index b86b1fd7e09..0800806638f 100644
---
a/server/src/main/java/org/apache/druid/segment/loading/VirtualPlaceholderSegment.java
+++
b/server/src/main/java/org/apache/druid/segment/loading/VirtualPlaceholderSegment.java
@@ -23,6 +23,7 @@ import org.apache.druid.data.input.Row;
import org.apache.druid.java.util.common.guava.Sequences;
import org.apache.druid.segment.DimensionDictionarySelector;
import org.apache.druid.segment.PhysicalSegmentColumnInspector;
+import org.apache.druid.segment.ReferenceCountedSegmentProvider;
import org.apache.druid.segment.RowAdapters;
import org.apache.druid.segment.RowBasedSegment;
import org.apache.druid.segment.RowCountInspector;
@@ -41,9 +42,13 @@ import javax.annotation.Nullable;
/**
* Empty segment with nothing but a time column, used when {@link
SegmentLoaderConfig#isVirtualStorage()} is true
* to create synthetic segments for {@link
org.apache.druid.query.metadata.metadata.SegmentMetadataQuery} so to not
- * force download of segments from deep storage when computing the SQL schema.
+ * force download of segments from deep storage when computing the SQL schema.
Also what
+ * {@link AcquireMode#PARTIAL_RULE_HELD} acquires for a segment that has no
partial-load rule, so it serves no rows.
+ * <p>
+ * Holds nothing in the cache, so it is a {@link
ReferenceCountedSegmentProvider.LeafReference} whose close does nothing.
*/
public class VirtualPlaceholderSegment extends RowBasedSegment<Row>
+ implements ReferenceCountedSegmentProvider.LeafReference
{
private final DataSegment dataSegment;
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 587f5280a38..5ae5f4115ef 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
@@ -40,9 +40,16 @@ import org.apache.druid.math.expr.ExprMacroTable;
import org.apache.druid.query.aggregation.CountAggregatorFactory;
import org.apache.druid.query.aggregation.LongSumAggregatorFactory;
import org.apache.druid.query.expression.TestExprMacroTable;
+import org.apache.druid.segment.AsyncCursorHolder;
+import org.apache.druid.segment.Cursor;
+import org.apache.druid.segment.CursorBuildSpec;
+import org.apache.druid.segment.CursorFactory;
+import org.apache.druid.segment.CursorHolder;
import org.apache.druid.segment.IndexBuilder;
import org.apache.druid.segment.IndexIO;
import org.apache.druid.segment.IndexSpec;
+import org.apache.druid.segment.PartialQueryableIndexSegment;
+import org.apache.druid.segment.Segment;
import org.apache.druid.segment.SegmentLazyLoadFailCallback;
import org.apache.druid.segment.TestHelper;
import org.apache.druid.segment.column.ColumnConfig;
@@ -50,6 +57,9 @@ import org.apache.druid.segment.column.ColumnType;
import org.apache.druid.segment.column.RowSignature;
import org.apache.druid.segment.data.CompressionStrategy;
import org.apache.druid.segment.file.PartialSegmentFileMapperV10;
+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.incremental.IncrementalIndexSchema;
import org.apache.druid.segment.projections.Projections;
import
org.apache.druid.segment.writeout.OffHeapMemorySegmentWriteOutMediumFactory;
@@ -69,12 +79,20 @@ import org.junit.jupiter.api.extension.RegisterExtension;
import javax.annotation.Nullable;
import java.io.File;
+import java.io.FileOutputStream;
import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.file.Files;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
/**
* Manager-level tests for the partial-load-rule {@link
SegmentLocalCacheManager#load} path: a segment whose
@@ -120,6 +138,22 @@ class SegmentLocalCacheManagerPartialRuleLoadTest
.build()
);
+ // group by dim1 + count, which both projections can answer
+ private static final CursorBuildSpec COUNT_BY_DIM1 =
+ CursorBuildSpec.builder()
+ .setGroupingColumns(List.of("dim1"))
+ .setAggregators(List.of(new
CountAggregatorFactory("_count")))
+ .setPhysicalColumns(Set.of("dim1"))
+ .build();
+
+ // group by dim1 + sum(metric1), which only AGG_BUNDLE (or the base table)
can answer
+ private static final CursorBuildSpec SUM_BY_DIM1 =
+ CursorBuildSpec.builder()
+ .setGroupingColumns(List.of("dim1"))
+ .setAggregators(List.of(new
LongSumAggregatorFactory("_metric1_sum", "metric1")))
+ .setPhysicalColumns(Set.of("dim1", "metric1"))
+ .build();
+
private static final List<InputRow> ROWS = Arrays.asList(
new ListBasedInputRow(ROW_SIGNATURE, TIME,
ROW_SIGNATURE.getColumnNames(), Arrays.asList("a", 1L)),
new ListBasedInputRow(ROW_SIGNATURE, TIME.plusMinutes(1),
ROW_SIGNATURE.getColumnNames(), Arrays.asList("a", 2L)),
@@ -131,6 +165,10 @@ class SegmentLocalCacheManagerPartialRuleLoadTest
public static final TemporaryFolderExtension SHARED_TEMPORARY_FOLDER =
TemporaryFolderExtension.classScoped();
private static File DEEP_STORAGE_DIR;
+ // the same segment, laid out as a legacy V10 segment whose only bundle is
the root bundle
+ private static File LEGACY_ROOT_ONLY_DEEP_STORAGE_DIR;
+ // as above, but also without per-column file lists
+ private static File LEGACY_ROOT_ONLY_NO_COLUMN_FILES_DEEP_STORAGE_DIR;
@RegisterExtension
public final TemporaryFolderExtension temporaryFolder =
TemporaryFolderExtension.testCaseScoped();
@@ -169,9 +207,59 @@ class SegmentLocalCacheManagerPartialRuleLoadTest
.build())
.rows(ROWS)
.buildMMappedIndexFile();
+ LEGACY_ROOT_ONLY_DEEP_STORAGE_DIR = writeRootOnlyCopy(
+ DEEP_STORAGE_DIR,
+ SHARED_TEMPORARY_FOLDER.newFolder("legacy_" +
ThreadLocalRandom.current().nextInt()),
+ false
+ );
+ LEGACY_ROOT_ONLY_NO_COLUMN_FILES_DEEP_STORAGE_DIR = writeRootOnlyCopy(
+ DEEP_STORAGE_DIR,
+ SHARED_TEMPORARY_FOLDER.newFolder("legacy_no_column_files_" +
ThreadLocalRandom.current().nextInt()),
+ true
+ );
EmittingLogger.registerEmitter(new NoopServiceEmitter());
}
+ /**
+ * Writes a copy of the V10 segment in {@code sourceDir} to {@code
targetDir} with every container re-tagged to the
+ * root bundle, the layout of a legacy segment written before bundle names
were recorded. Container offsets are
+ * relative to the end of the header, so only the metadata changes.
+ *
+ * @param dropColumnFiles also drop the per-column file lists, which legacy
segments predate, so prefetches fall back
+ * to downloading whole bundles
+ */
+ private static File writeRootOnlyCopy(File sourceDir, File targetDir,
boolean dropColumnFiles) throws IOException
+ {
+ final ObjectMapper mapper = TestHelper.makeJsonMapper();
+ final byte[] source = Files.readAllBytes(new File(sourceDir,
IndexIO.V10_FILE_NAME).toPath());
+ Assertions.assertEquals(IndexIO.V10_VERSION, source[0]);
+ Assertions.assertEquals(CompressionStrategy.NONE.getId(), source[1],
"fixture metadata must be uncompressed");
+ final int metadataLength = ByteBuffer.wrap(source, 2,
Integer.BYTES).order(ByteOrder.LITTLE_ENDIAN).getInt();
+ final int headerLength = 2 + Integer.BYTES + metadataLength;
+ final SegmentFileMetadata metadata =
+ mapper.readValue(source, 2 + Integer.BYTES, metadataLength,
SegmentFileMetadata.class);
+ final SegmentFileMetadata rootOnly = new SegmentFileMetadata(
+ metadata.getContainers()
+ .stream()
+ .map(container -> new
SegmentFileContainerMetadata(container.getStartOffset(), container.getSize(),
null))
+ .toList(),
+ metadata.getFiles(),
+ metadata.getInterval(),
+ metadata.getColumnDescriptors(),
+ dropColumnFiles ? null : metadata.getColumnFiles(),
+ metadata.getProjections(),
+ metadata.getBitmapEncoding()
+ );
+ final byte[] rootOnlyMetadata = mapper.writeValueAsBytes(rootOnly);
+ try (OutputStream out = new FileOutputStream(new File(targetDir,
IndexIO.V10_FILE_NAME))) {
+ out.write(new byte[]{IndexIO.V10_VERSION,
CompressionStrategy.NONE.getId()});
+
out.write(ByteBuffer.allocate(Integer.BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(rootOnlyMetadata.length).array());
+ out.write(rootOnlyMetadata);
+ out.write(source, headerLength, source.length - headerLength);
+ }
+ return targetDir;
+ }
+
@BeforeEach
void setup() throws IOException
{
@@ -1064,6 +1152,221 @@ class SegmentLocalCacheManagerPartialRuleLoadTest
}
}
+ @Test
+ void testRuleHeldAcquireServesOnlySelectedBundles() throws Exception
+ {
+ manager = makeManager(true, true);
+ final StorageLocation location = manager.getLocations().get(0);
+ final DataSegment segment =
partialWrapperSegment(List.of(OTHER_AGG_BUNDLE));
+ manager.load(segment);
+ final long loadsAfterRule = location.getWeakStats().getLoadCount();
+
+ try (AcquireSegmentAction action = manager.acquireSegment(segment,
AcquireMode.PARTIAL_RULE_HELD)) {
+ action.await();
+ try (Segment acquired = action.release().getSegment().orElseThrow()) {
+ Assertions.assertInstanceOf(PartialQueryableIndexSegment.class,
acquired);
+ Assertions.assertEquals(2, countRows(acquired, COUNT_BY_DIM1), "served
from the selected projection");
+ // only the unselected projection has the sum, and the base table
fallback is resident (as a dependency of the
+ // selected projection) but was not selected itself
+ Assertions.assertEquals(0, countRows(acquired, SUM_BY_DIM1));
+ Assertions.assertEquals(0, countRows(acquired,
CursorBuildSpec.FULL_SCAN));
+ }
+ }
+
+ // the cached fast path is restricted the same way
+ final Segment cached = manager.acquireCachedSegment(SEGMENT_ID,
AcquireMode.PARTIAL_RULE_HELD).orElseThrow();
+ try (cached) {
+ Assertions.assertEquals(2, countRows(cached, COUNT_BY_DIM1));
+ Assertions.assertEquals(0, countRows(cached, SUM_BY_DIM1));
+ }
+
+ Assertions.assertNull(
+ location.getCacheEntry(new
PartialSegmentBundleCacheEntryIdentifier(SEGMENT_ID, AGG_BUNDLE)),
+ "the unselected projection must never be loaded"
+ );
+ Assertions.assertEquals(loadsAfterRule,
location.getWeakStats().getLoadCount(), "nothing new was loaded");
+ }
+
+ @Test
+ void testRuleHeldAcquireDownloadsSelectedBundleOnDemand() throws Exception
+ {
+ manager = makeManager(true, true);
+ final StorageLocation location = manager.getLocations().get(0);
+ final PartialSegmentBundleCacheEntryIdentifier aggId =
+ new PartialSegmentBundleCacheEntryIdentifier(SEGMENT_ID, AGG_BUNDLE);
+
+ // mount the metadata the way an on-demand query would, then select a
bundle that has not been loaded, as the rule
+ // state looks while a rule's eager downloads are still running
+ try (AcquireSegmentAction action = manager.acquireSegment(plainSegment(),
AcquireMode.PARTIAL)) {
+ action.await();
+ action.release().getSegment().orElseThrow().close();
+ }
+ final PartialSegmentMetadataCacheEntry metadata =
weakReservedMetadata(location, SEGMENT_ID);
+ metadata.applyRule(FINGERPRINT, Set.of(AGG_BUNDLE));
+ Assertions.assertNull(location.getCacheEntry(aggId), "precondition: the
selected bundle is not loaded yet");
+
+ final Segment cached = manager.acquireCachedSegment(SEGMENT_ID,
AcquireMode.PARTIAL_RULE_HELD).orElseThrow();
+ try (cached) {
+ Assertions.assertEquals(2, countRows(cached, SUM_BY_DIM1));
+ Assertions.assertEquals(0, countRows(cached, CursorBuildSpec.FULL_SCAN),
"the base table was not selected");
+ }
+ Assertions.assertTrue(location.isWeakReserved(aggId), "the selected bundle
was loaded on demand");
+ }
+
+ @Test
+ void testRuleHeldAcquireOfEntryWithoutRuleServesNothing() throws Exception
+ {
+ manager = makeManager(true, true);
+ final StorageLocation location = manager.getLocations().get(0);
+
+ // an entry that exists only because a query loaded it has no rule applied
+ try (AcquireSegmentAction action = manager.acquireSegment(plainSegment(),
AcquireMode.PARTIAL)) {
+ action.await();
+ action.release().getSegment().orElseThrow().close();
+ }
+ final long loadsBefore = location.getWeakStats().getLoadCount();
+
+ final Segment cached = manager.acquireCachedSegment(SEGMENT_ID,
AcquireMode.PARTIAL_RULE_HELD).orElseThrow();
+ try (cached) {
+ Assertions.assertEquals(0, countRows(cached, COUNT_BY_DIM1));
+ Assertions.assertEquals(0, countRows(cached, CursorBuildSpec.FULL_SCAN));
+ }
+ Assertions.assertEquals(loadsBefore,
location.getWeakStats().getLoadCount(), "nothing was loaded");
+ }
+
+ @Test
+ void testRuleHeldAcquireOfUncachedSegmentLoadsNothing() throws Exception
+ {
+ manager = makeManager(true, true);
+ final StorageLocation location = manager.getLocations().get(0);
+
+ Assertions.assertTrue(manager.acquireCachedSegment(SEGMENT_ID,
AcquireMode.PARTIAL_RULE_HELD).isEmpty());
+ try (AcquireSegmentAction action = manager.acquireSegment(plainSegment(),
AcquireMode.PARTIAL_RULE_HELD)) {
+ Assertions.assertTrue(action.isReady(), "nothing to load, so the action
is ready immediately");
+ try (Segment acquired = action.release().getSegment().orElseThrow()) {
+ Assertions.assertInstanceOf(VirtualPlaceholderSegment.class, acquired);
+ Assertions.assertEquals(SEGMENT_ID, acquired.getId());
+ Assertions.assertEquals(0, countRows(acquired, COUNT_BY_DIM1));
+ Assertions.assertEquals(0, countRows(acquired,
CursorBuildSpec.FULL_SCAN));
+ }
+ }
+ Assertions.assertNull(
+ location.getCacheEntry(new SegmentCacheEntryIdentifier(SEGMENT_ID)),
+ "no cache entry may be created"
+ );
+ Assertions.assertEquals(0, location.getWeakStats().getLoadCount());
+ }
+
+ @Test
+ void testProjectionRuleOnLegacyRootOnlySegmentHoldsRootBundle() throws
Exception
+ {
+ // On a legacy segment the root bundle physically holds everything,
including the selected projection, so the rule
+ // must resolve its selection to the root bundle: that's what gets
rule-held, eagerly downloaded, and served under
+ // PARTIAL_RULE_HELD. Without resolving, the rule holds and downloads
nothing, and PARTIAL_RULE_HELD reads nothing.
+ manager = makeManager(true, true);
+ final StorageLocation location = manager.getLocations().get(0);
+ final DataSegment segment =
+ partialWrapperSegment(List.of(OTHER_AGG_BUNDLE), FINGERPRINT,
LEGACY_ROOT_ONLY_DEEP_STORAGE_DIR);
+ manager.load(segment);
+
+ final PartialSegmentMetadataCacheEntry metadata =
weakReservedMetadata(location, SEGMENT_ID);
+ final PartialSegmentFileMapperV10 mapper = metadata.getFileMapper();
+ Assertions.assertNotNull(mapper);
+ Assertions.assertEquals(
+ Set.of(SegmentFileBuilder.ROOT_BUNDLE_NAME),
+ PartialSegmentBundleCacheEntry.bundleNames(mapper),
+ "test setup: the segment must have only the root bundle"
+ );
+ Assertions.assertEquals(Set.of(SegmentFileBuilder.ROOT_BUNDLE_NAME),
metadata.getRuleSelectedBundleNames());
+
Assertions.assertTrue(metadata.isBundleRuleHeld(SegmentFileBuilder.ROOT_BUNDLE_NAME));
+
Assertions.assertTrue(mapper.isBundleFullyDownloaded(SegmentFileBuilder.ROOT_BUNDLE_NAME));
+ final long loadsAfterRule = location.getWeakStats().getLoadCount();
+
+ try (AcquireSegmentAction action = manager.acquireSegment(segment,
AcquireMode.PARTIAL_RULE_HELD)) {
+ action.await();
+ try (Segment acquired = action.release().getSegment().orElseThrow()) {
+ Assertions.assertEquals(2, countRows(acquired, COUNT_BY_DIM1), "served
from the selected projection");
+ // the root bundle is the only bundle, and it holds the other
projection and the base table too
+ Assertions.assertEquals(2, countRows(acquired, SUM_BY_DIM1));
+ Assertions.assertEquals(4, countRows(acquired,
CursorBuildSpec.FULL_SCAN));
+ }
+ }
+ Assertions.assertEquals(loadsAfterRule,
location.getWeakStats().getLoadCount(), "nothing new was loaded");
+ }
+
+ @Test
+ void testOnDemandAcquireOfLegacyRootOnlySegmentWithoutColumnFiles() throws
Exception
+ {
+ // Without column file lists the prefetch downloads whole bundles, and it
must resolve the projection it matched
+ // to the root bundle that physically holds it, or it plans nothing and
reading the columns fails.
+ manager = makeManager(true, true);
+ final StorageLocation location = manager.getLocations().get(0);
+
+ try (AcquireSegmentAction action =
+
manager.acquireSegment(plainSegment(LEGACY_ROOT_ONLY_NO_COLUMN_FILES_DEEP_STORAGE_DIR),
AcquireMode.PARTIAL)) {
+ action.await();
+ try (Segment acquired = action.release().getSegment().orElseThrow()) {
+ final PartialSegmentFileMapperV10 mapper =
weakReservedMetadata(location, SEGMENT_ID).getFileMapper();
+ Assertions.assertNotNull(mapper);
+
Assertions.assertNull(mapper.getSegmentFileMetadata().getColumnFiles(), "test
setup: no column file lists");
+
Assertions.assertFalse(mapper.isBundleFullyDownloaded(SegmentFileBuilder.ROOT_BUNDLE_NAME));
+
+ Assertions.assertEquals(2, countRows(acquired, COUNT_BY_DIM1));
+
Assertions.assertTrue(mapper.isBundleFullyDownloaded(SegmentFileBuilder.ROOT_BUNDLE_NAME));
+ }
+ }
+ }
+
+ @Test
+ void
testRuleHeldAcquireDownloadsSelectedRootBundleOfLegacySegmentWithoutColumnFiles()
throws Exception
+ {
+ // The selected root bundle is not resident yet, so PARTIAL_RULE_HELD must
download it on demand through the same
+ // whole-bundle prefetch.
+ manager = makeManager(true, true);
+ final StorageLocation location = manager.getLocations().get(0);
+
+ try (AcquireSegmentAction action =
+
manager.acquireSegment(plainSegment(LEGACY_ROOT_ONLY_NO_COLUMN_FILES_DEEP_STORAGE_DIR),
AcquireMode.PARTIAL)) {
+ action.await();
+ action.release().getSegment().orElseThrow().close();
+ }
+ final PartialSegmentMetadataCacheEntry metadata =
weakReservedMetadata(location, SEGMENT_ID);
+ metadata.applyRule(FINGERPRINT,
Set.of(SegmentFileBuilder.ROOT_BUNDLE_NAME));
+ final PartialSegmentFileMapperV10 mapper = metadata.getFileMapper();
+ Assertions.assertNotNull(mapper);
+
Assertions.assertFalse(mapper.isBundleFullyDownloaded(SegmentFileBuilder.ROOT_BUNDLE_NAME));
+
+ final Segment cached = manager.acquireCachedSegment(SEGMENT_ID,
AcquireMode.PARTIAL_RULE_HELD).orElseThrow();
+ try (cached) {
+ Assertions.assertEquals(2, countRows(cached, COUNT_BY_DIM1));
+ }
+
Assertions.assertTrue(mapper.isBundleFullyDownloaded(SegmentFileBuilder.ROOT_BUNDLE_NAME));
+ }
+
+ /**
+ * Builds an async cursor over {@code segment} for {@code spec}, waits for
it, and counts the rows it reads.
+ */
+ private static int countRows(Segment segment, CursorBuildSpec spec) throws
InterruptedException
+ {
+ try (AsyncCursorHolder asyncHolder =
segment.as(CursorFactory.class).makeCursorHolderAsync(spec)) {
+ final CountDownLatch ready = new CountDownLatch(1);
+ asyncHolder.addReadyCallback(ready::countDown);
+ Assertions.assertTrue(ready.await(15, TimeUnit.SECONDS), "async cursor
must materialize within the test timeout");
+ try (CursorHolder holder = asyncHolder.release()) {
+ final Cursor cursor = holder.asCursor();
+ if (cursor == null) {
+ return 0;
+ }
+ int rows = 0;
+ while (!cursor.isDone()) {
+ rows++;
+ cursor.advance();
+ }
+ return rows;
+ }
+ }
+ }
+
private static CacheEntry bundleEntry(StorageLocation location, String
bundleName)
{
final CacheEntry entry =
@@ -1122,10 +1425,15 @@ class SegmentLocalCacheManagerPartialRuleLoadTest
}
private DataSegment partialWrapperSegment(List<String> selectedProjections,
String fingerprint)
+ {
+ return partialWrapperSegment(selectedProjections, fingerprint,
DEEP_STORAGE_DIR);
+ }
+
+ private DataSegment partialWrapperSegment(List<String> selectedProjections,
String fingerprint, File deepStorageDir)
{
final Map<String, Object> delegate = Map.of(
"type", "local",
- "path", DEEP_STORAGE_DIR.getAbsolutePath()
+ "path", deepStorageDir.getAbsolutePath()
);
final Map<String, Object> wrapperWire =
PartialProjectionLoadSpec.wireForm(delegate, selectedProjections,
fingerprint);
@@ -1205,10 +1513,15 @@ class SegmentLocalCacheManagerPartialRuleLoadTest
* for a regular full load.
*/
private DataSegment plainSegment()
+ {
+ return plainSegment(DEEP_STORAGE_DIR);
+ }
+
+ private DataSegment plainSegment(File deepStorageDir)
{
return DataSegment.builder(SEGMENT_ID)
.shardSpec(NoneShardSpec.instance())
- .loadSpec(Map.of("type", "local", "path",
DEEP_STORAGE_DIR.getAbsolutePath()))
+ .loadSpec(Map.of("type", "local", "path",
deepStorageDir.getAbsolutePath()))
.size(0)
.build();
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]