diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/server/PartialLoadHistoricalCloningTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/server/PartialLoadHistoricalCloningTest.java new file mode 100644 index 000000000000..4ab23c6814e2 --- /dev/null +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/server/PartialLoadHistoricalCloningTest.java @@ -0,0 +1,329 @@ +/* + * 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.testing.embedded.server; + +import org.apache.druid.common.utils.IdUtils; +import org.apache.druid.data.input.impl.AggregateProjectionSpec; +import org.apache.druid.data.input.impl.ClusteredValueGroupsBaseTableProjectionSpec; +import org.apache.druid.data.input.impl.LongDimensionSchema; +import org.apache.druid.data.input.impl.StringDimensionSchema; +import org.apache.druid.data.input.impl.TimestampSpec; +import org.apache.druid.indexer.granularity.SegmentGranularitySpec; +import org.apache.druid.indexing.common.task.TaskBuilder; +import org.apache.druid.indexing.common.task.batch.parallel.ParallelIndexSupervisorTask; +import org.apache.druid.java.util.common.HumanReadableBytes; +import org.apache.druid.java.util.common.Intervals; +import org.apache.druid.java.util.common.StringUtils; +import org.apache.druid.java.util.common.granularity.Granularities; +import org.apache.druid.query.DruidMetrics; +import org.apache.druid.query.aggregation.LongMinAggregatorFactory; +import org.apache.druid.query.aggregation.LongSumAggregatorFactory; +import org.apache.druid.server.coordinator.CoordinatorDynamicConfig; +import org.apache.druid.server.coordinator.rules.CannotMatchBehavior; +import org.apache.druid.server.coordinator.rules.ForeverPartialLoadRule; +import org.apache.druid.server.coordinator.rules.WildcardProjectionPartialLoadMatcher; +import org.apache.druid.testing.embedded.EmbeddedBroker; +import org.apache.druid.testing.embedded.EmbeddedCoordinator; +import org.apache.druid.testing.embedded.EmbeddedDruidCluster; +import org.apache.druid.testing.embedded.EmbeddedHistorical; +import org.apache.druid.testing.embedded.EmbeddedIndexer; +import org.apache.druid.testing.embedded.EmbeddedOverlord; +import org.apache.druid.testing.embedded.EmbeddedRouter; +import org.apache.druid.testing.embedded.junit5.EmbeddedClusterTestBase; +import org.apache.druid.testing.embedded.utils.ITRetryUtil; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.Map; + +/** + * End-to-end coverage for cloning a historical that loads segments partially. The source historical loads only the + * bundles a {@link ForeverPartialLoadRule} selects, so it reports a footprint smaller than the full segment. Its + * clone is expected to hold and report the same footprint: cloning copies the source's load state, and under a + * partial-load rule that state includes which parts of the segment were loaded. + *
+ * Both historicals are configured identically for partial downloads, so any difference in reported {@code curr_size} + * comes from the load request itself rather than from node configuration. + */ +public class PartialLoadHistoricalCloningTest extends EmbeddedClusterTestBase +{ + private static final String PROJECTION_NAME = "country_delta"; + // Ingested alongside country_delta but not selected by the rule, so its container bytes stay off the historical's + // disk. That is what makes the rule-loaded footprint measurably smaller than the full segment size. + private static final String UNMATCHED_PROJECTION_NAME = "country_min_delta"; + + private static final long CACHE_SIZE = HumanReadableBytes.parse("1MiB"); + private static final long MAX_SIZE = HumanReadableBytes.parse("100MiB"); + private static final long ESTIMATE_SIZE = HumanReadableBytes.parse("2KiB"); + + private static final String CLONE_PORT = "7083"; + + private final EmbeddedBroker broker = new EmbeddedBroker(); + private final EmbeddedIndexer indexer = new EmbeddedIndexer(); + private final EmbeddedOverlord overlord = new EmbeddedOverlord(); + private final EmbeddedCoordinator coordinator = new EmbeddedCoordinator(); + private final EmbeddedRouter router = new EmbeddedRouter(); + + private final EmbeddedHistorical sourceHistorical = new EmbeddedHistorical(); + private final EmbeddedHistorical cloneHistorical = + new EmbeddedHistorical().addProperty("druid.plaintextPort", CLONE_PORT); + + @Override + public EmbeddedDruidCluster createCluster() + { + configureForPartialDownloads(sourceHistorical); + configureForPartialDownloads(cloneHistorical); + + broker.setServerMemory(200_000_000) + .addProperty("druid.sql.planner.enableSysQueriesTable", "true"); + + coordinator.addProperty("druid.manager.segments.useIncrementalCache", "always"); + + overlord.addProperty("druid.manager.segments.useIncrementalCache", "always") + .addProperty("druid.manager.segments.pollDuration", "PT0.1s"); + + indexer.setServerMemory(300_000_000) + .addProperty("druid.worker.capacity", "2") + .addProperty("druid.processing.numThreads", "2") + .addProperty("druid.segment.handoff.pollDuration", "PT0.1s"); + + return EmbeddedDruidCluster + .withEmbeddedDerbyAndZookeeper() + .useLatchableEmitter() + .useDefaultTimeoutForLatchableEmitter(60) + .addCommonProperty("druid.indexer.task.buildV10", "true") + .addCommonProperty("druid.storage.type", "local") + .addCommonProperty("druid.storage.zip", "false") + .addServer(coordinator) + .addServer(overlord) + .addServer(indexer) + .addServer(sourceHistorical) + .addServer(cloneHistorical) + .addServer(broker) + .addServer(router); + } + + private void configureForPartialDownloads(EmbeddedHistorical historical) + { + historical.setServerMemory(500_000_000) + .addProperty("druid.segmentCache.virtualStorage", "true") + .addProperty("druid.segmentCache.virtualStoragePartialDownloadsEnabled", "true") + .addProperty( + "druid.segmentCache.virtualStorageMetadataReservationEstimate", + String.valueOf(ESTIMATE_SIZE) + ) + .addProperty( + "druid.segmentCache.virtualStorageLoadThreads", + String.valueOf(Runtime.getRuntime().availableProcessors()) + ) + .addBeforeStartHook( + (cluster, self) -> self.addProperty( + "druid.segmentCache.locations", + StringUtils.format( + "[{\"path\":\"%s\",\"maxSize\":\"%s\"}]", + cluster.getTestFolder().newFolder().getAbsolutePath(), + CACHE_SIZE + ) + ) + ) + .addProperty("druid.server.maxSize", String.valueOf(MAX_SIZE)); + } + + @BeforeAll + void loadDataAndConfigureCloning() throws IOException + { + dataSource = "partial-clone-" + IdUtils.getRandomId(); + + // The rule and the clone mapping are both configured before ingestion so the first coordinator run already sees + // the clone target as unmanaged: rule-driven assignment can only pick the source, and everything the clone gets + // comes from the cloning duty. + cluster.callApi().onLeaderCoordinator( + c -> c.updateRulesForDatasource( + dataSource, + List.of( + new ForeverPartialLoadRule( + Map.of("_default_tier", 1), + null, + new WildcardProjectionPartialLoadMatcher(List.of(PROJECTION_NAME), null), + CannotMatchBehavior.FALL_THROUGH + ) + ) + ) + ); + cluster.callApi().onLeaderCoordinator( + c -> c.updateCoordinatorDynamicConfig( + CoordinatorDynamicConfig + .builder() + .withCloneServers(Map.of(cloneHost(), sourceHost())) + .build() + ) + ); + + ingestClusteredSegmentWithProjection(); + } + + @Override + protected void refreshDatasourceName() + { + // Fixed datasource across tests — rule, clone mapping and ingest are one-time setup. + } + + @Test + void testCloneReportsTheSamePartialFootprintAsItsSource() + { + coordinator.latchableEmitter().waitForEventAggregate( + event -> event.hasMetricName("segment/clone/assigned/count") + .hasDimension("server", cloneHost()), + agg -> agg.hasSumAtLeast(1) + ); + coordinator.latchableEmitter().waitForEventAggregate( + event -> event.hasMetricName("segment/loadQueue/success") + .hasDimension("server", cloneHost()) + .hasDimension(DruidMetrics.DATASOURCE, dataSource), + agg -> agg.hasSumAtLeast(1) + ); + + // The load announcement reaches the broker's inventory asynchronously; wait until both historicals have reported + // a footprint before comparing them. + ITRetryUtil.retryUntilTrue( + () -> currSizeOf(sourceHost()) > 0 && currSizeOf(cloneHost()) > 0, + "both historicals to report a non-zero curr_size" + ); + + final long fullSize = Long.parseLong( + cluster.callApi().runSql( + "SELECT \"size\" FROM sys.segments WHERE datasource = '" + dataSource + "'" + ).trim() + ); + final long sourceSize = currSizeOf(sourceHost()); + final long cloneSize = currSizeOf(cloneHost()); + + Assertions.assertTrue( + sourceSize < fullSize, + StringUtils.format( + "source should hold only the rule-selected parts; got curr_size=%d, full segment size=%d", + sourceSize, + fullSize + ) + ); + Assertions.assertEquals( + sourceSize, + cloneSize, + StringUtils.format( + "clone should hold the same parts as its source; source curr_size=%d, clone curr_size=%d, " + + "full segment size=%d (a clone loaded without the source's partial-load profile downloads the whole " + + "segment and reports its full size)", + sourceSize, + cloneSize, + fullSize + ) + ); + } + + private long currSizeOf(String host) + { + final String result = cluster.callApi().runSql( + "SELECT curr_size FROM sys.servers WHERE server_type = 'historical' AND server = '" + host + "'" + ).trim(); + return result.isEmpty() ? 0L : Long.parseLong(result); + } + + private String sourceHost() + { + return sourceHistorical.bindings().selfNode().getHostAndPort(); + } + + private String cloneHost() + { + return cloneHistorical.bindings().selfNode().getHostAndPort(); + } + + /** + * Ingests a single clustered base-table segment (clustered by {@code channel}) with a {@code country_delta} + * aggregate projection (group by {@code countryName}, sum {@code delta}) plus a second projection the rule does + * not select. + */ + private void ingestClusteredSegmentWithProjection() throws IOException + { + final File tmpDir = cluster.getTestFolder().newFolder(); + final File inputFile = new File(tmpDir, "clustered-input.json"); + final String inputData = + "{\"time\":\"2024-01-01T00:10:00Z\",\"channel\":\"#en\",\"countryName\":\"US\",\"delta\":10}\n" + + "{\"time\":\"2024-01-01T00:20:00Z\",\"channel\":\"#en\",\"countryName\":\"US\",\"delta\":5}\n" + + "{\"time\":\"2024-01-01T00:30:00Z\",\"channel\":\"#en\",\"countryName\":\"CA\",\"delta\":3}\n" + + "{\"time\":\"2024-01-01T00:40:00Z\",\"channel\":\"#fr\",\"countryName\":\"FR\",\"delta\":7}\n" + + "{\"time\":\"2024-01-01T00:50:00Z\",\"channel\":\"#fr\",\"countryName\":\"US\",\"delta\":2}\n"; + Files.write(inputFile.toPath(), inputData.getBytes(StandardCharsets.UTF_8)); + + final ClusteredValueGroupsBaseTableProjectionSpec clusterSpec = + ClusteredValueGroupsBaseTableProjectionSpec.builder() + .columns( + new StringDimensionSchema("channel"), + new StringDimensionSchema("countryName"), + new LongDimensionSchema("delta"), + new LongDimensionSchema("__time") + ) + .clusteringColumns("channel") + .build(); + + final AggregateProjectionSpec projection = + AggregateProjectionSpec.builder(PROJECTION_NAME) + .groupingColumns(new StringDimensionSchema("countryName")) + .aggregators(new LongSumAggregatorFactory("sumDelta", "delta")) + .build(); + + final AggregateProjectionSpec unmatchedProjection = + AggregateProjectionSpec.builder(UNMATCHED_PROJECTION_NAME) + .groupingColumns(new StringDimensionSchema("countryName")) + .aggregators(new LongMinAggregatorFactory("minDelta", "delta")) + .build(); + + final SegmentGranularitySpec segmentGranularitySpec = new SegmentGranularitySpec( + Granularities.HOUR, + List.of(Intervals.of("2024-01-01/2024-01-02")) + ); + + final String taskId = IdUtils.getRandomId(); + final ParallelIndexSupervisorTask task = TaskBuilder + .ofTypeIndexParallel() + .jsonInputFormat() + .localInputSourceWithFiles(inputFile) + .dataSchema( + builder -> builder + .withDataSource(dataSource) + .withTimestamp(new TimestampSpec("time", "iso", null)) + .withSegmentGranularity(segmentGranularitySpec) + .withBaseTable(clusterSpec) + .withProjections(List.of(projection, unmatchedProjection)) + ) + .tuningConfig(t -> t.withMaxNumConcurrentSubTasks(1)) + .withId(taskId); + + cluster.callApi().onLeaderOverlord(o -> o.runTask(taskId, task)); + cluster.callApi().waitForTaskToSucceed(taskId, overlord); + cluster.callApi().waitForAllSegmentsToBeAvailable(dataSource, coordinator, broker); + } +} 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 8ee13001f237..4b442bfd2f00 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 @@ -465,13 +465,13 @@ private void deleteSegmentInfoFile(DataSegment segment) } /** - * Write the info file for a partial-load segment, overwriting any existing content atomically. Distinct from - * {@link #storeInfoFile} which skips the write when the file already exists, for partial segments we must - * unconditionally rewrite so an incoming rule swap (new {@code fingerprint}/{@code delegate} inside the - * wrapped load spec) reaches disk. Otherwise bootstrap after a restart would restore the segment using the - * prior wrapper and re-announce the old rule until the coordinator resyncs. + * Write the info file for a segment, overwriting any existing content atomically. Distinct from + * {@link #storeInfoFile}, which skips the write when the file already exists: a partial-load transition must reach + * disk unconditionally, whether it is a rule swap (new {@code fingerprint}/{@code delegate} inside the wrapped load + * spec) or a return to a regular full load (no wrapper at all). Otherwise bootstrap after a restart would restore + * the segment using the prior wrapper and re-announce the old rule until the coordinator resyncs. */ - private void writePartialInfoFile(DataSegment segment) throws IOException + private void rewriteInfoFile(DataSegment segment) throws IOException { final File segmentInfoCacheFile = new File(getEffectiveInfoDir(), segment.getId().toString()); FileUtils.mkdirp(getEffectiveInfoDir()); @@ -903,7 +903,7 @@ private ReservedPartial reservePartial(DataSegment dataSegment, SegmentRangeRead location.getPath() ); } - writePartialInfoFile(dataSegment); + rewriteInfoFile(dataSegment); partial.setOnUnmount(() -> deleteSegmentInfoFile(dataSegment)); return new ReservedPartial(partial, location, hold); } @@ -1041,7 +1041,7 @@ private DataSegment loadPartial(DataSegment dataSegment) throws SegmentLoadingEx // branch. On the find-existing branch the info file on disk still carries the PRIOR rule's wrapped // load spec, so a rule swap here would apply in memory only. Rewrite unconditionally before mount. try { - writePartialInfoFile(dataSegment); + rewriteInfoFile(dataSegment); } catch (IOException e) { throw new SegmentLoadingException( @@ -1338,15 +1338,21 @@ public DataSegment load(final DataSegment dataSegment) throws SegmentLoadingExce return loadPartial(dataSegment); } // virtual storage doesn't do anything with loading immediately, but check to see if the segment is already cached - // and if so, clear out the onUnmount action + // and if so, clear out the onUnmount action. Reaching here with a rule applied means the coordinator asked for + // the whole segment again: a rule is only ever applied on the loadPartial path above, so the request that got + // here carries no partial-load wrapper for this segment. Release the rule. final ReferenceCountingLock lock = lock(dataSegment); synchronized (lock) { try { final SegmentCacheEntryIdentifier cacheEntryIdentifier = new SegmentCacheEntryIdentifier(dataSegment.getId()); for (StorageLocation location : locations) { final SegmentCacheEntry cacheEntry = location.getCacheEntry(cacheEntryIdentifier); - if (cacheEntry != null) { - cacheEntry.setOnUnmount(null); + if (cacheEntry == null) { + continue; + } + cacheEntry.setOnUnmount(null); + if (cacheEntry instanceof PartialSegmentMetadataCacheEntry partial && partial.isRuleHeld()) { + releaseRuleForFullLoad(dataSegment, partial); } } } @@ -1426,7 +1432,7 @@ public DataSegment bootstrap( reapplyRuleFromInfoFile(dataSegment, partial); loadedProfile = PartialLoadProfile.forLoaded( dataSegment.getLoadSpec(), - (String) dataSegment.getLoadSpec().get("fingerprint"), + (String) dataSegment.getLoadSpec().get(PartialLoadSpec.FINGERPRINT_FIELD), partial.getRealizedBytes() ); } @@ -1526,6 +1532,48 @@ public void drop(final DataSegment segment) } } + /** + * Releases the partial-load rule applied to {@code dataSegment} in response to an unwrapped load request: the + * coordinator has stopped asking for parts of the segment, so the metadata entry and the rule's bundles are unpinned. + * That is what a full load means under virtual storage — nothing is pinned, each part is fetched on demand — and + * reclaim of the partial state on disk is left to eviction, as it is for {@link #drop}. + *
+ * The info file is rewritten before the rule is cleared, and a failed rewrite fails the load. Nothing is left half + * converted: releasing the holds cannot fail, and a load failure sends the historical down its drop path, which + * clears the rule and removes the info file, so there is no stale rule for a restart to reinstate. Leaving the rule + * applied and carrying on is not an option, because an unwrapped request announces as a full load either way, so the + * coordinator would record a replica with no profile and never ask again. + *
+ * Callers must hold this segment's {@link #lock(DataSegment)}, which is the external lock that
+ * {@link PartialSegmentMetadataCacheEntry#clearRule} requires to be serialized against
+ * {@link PartialSegmentMetadataCacheEntry#applyRule}.
+ */
+ private void releaseRuleForFullLoad(DataSegment dataSegment, PartialSegmentMetadataCacheEntry partial)
+ throws SegmentLoadingException
+ {
+ // Snapshot both before clearRule zeroes out the rule state so the log can describe what was released.
+ final String priorFingerprint = partial.getRuleFingerprint();
+ final long priorRealizedBytes = partial.getRealizedBytes();
+ try {
+ rewriteInfoFile(dataSegment);
+ }
+ catch (IOException e) {
+ throw new SegmentLoadingException(
+ e,
+ "Failed to rewrite info file for segment[%s] while releasing partial-load rule[fingerprint=%s]",
+ dataSegment.getId(),
+ priorFingerprint
+ );
+ }
+ partial.clearRule();
+ log.info(
+ "Released partial-load rule[fingerprint=%s, realizedBytes=%d] for segment[%s]; it is a regular full load now.",
+ priorFingerprint,
+ priorRealizedBytes,
+ dataSegment.getId()
+ );
+ }
+
/**
* Reapply the persisted partial-load rule to a bootstrap-restored metadata entry. Reads the wrapper from the
* segment's info-file {@code loadSpec}, resolves the selected bundle names against the just-parsed on-disk
diff --git a/server/src/main/java/org/apache/druid/server/coordination/SegmentChangeRequestLoad.java b/server/src/main/java/org/apache/druid/server/coordination/SegmentChangeRequestLoad.java
index c6b5f4ac2904..d6f4a04289e5 100644
--- a/server/src/main/java/org/apache/druid/server/coordination/SegmentChangeRequestLoad.java
+++ b/server/src/main/java/org/apache/druid/server/coordination/SegmentChangeRequestLoad.java
@@ -74,7 +74,11 @@ public static SegmentChangeRequestLoad forAnnouncement(DataSegment segment)
final Map
+ * Read this before {@link #cancelOperation}, which clears the in-flight profile.
+ */
+ @Nullable
+ public PartialLoadProfile getProjectedProfile(DataSegment segment)
+ {
+ final SegmentAction action = getActionOnSegment(segment);
+ if (action != null && action.isLoad()) {
+ return getInFlightProfile(segment);
+ }
+ return server.getPartialLoadProfile(segment.getId());
+ }
+
private boolean hasSegmentLoaded(SegmentId segmentId)
{
return server.getSegment(segmentId) != null;
diff --git a/server/src/main/java/org/apache/druid/server/coordinator/duty/CloneHistoricals.java b/server/src/main/java/org/apache/druid/server/coordinator/duty/CloneHistoricals.java
index c12193c9b0e5..902ed31a5733 100644
--- a/server/src/main/java/org/apache/druid/server/coordinator/duty/CloneHistoricals.java
+++ b/server/src/main/java/org/apache/druid/server/coordinator/duty/CloneHistoricals.java
@@ -27,6 +27,7 @@
import org.apache.druid.server.coordinator.DruidCoordinatorRuntimeParams;
import org.apache.druid.server.coordinator.ServerCloneStatus;
import org.apache.druid.server.coordinator.ServerHolder;
+import org.apache.druid.server.coordinator.loading.PartialLoadProfile;
import org.apache.druid.server.coordinator.loading.SegmentAction;
import org.apache.druid.server.coordinator.loading.SegmentLoadQueueManager;
import org.apache.druid.server.coordinator.stats.Dimension;
@@ -38,6 +39,7 @@
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@@ -45,6 +47,13 @@
* Handles cloning of historicals. Given the historical to historical clone mappings, based on
* {@link CoordinatorDynamicConfig#getCloneServers()}, copies any segments load or unload requests from the source
* historical to the target historical.
+ *
+ * Under a partial-load rule the source holds only part of a segment, so copying its load state means copying the
+ * {@link PartialLoadProfile} it holds the segment under, not just the segment id. Replicas are therefore compared by
+ * profile fingerprint: a clone whose replica was loaded under a different profile than the source's is re-loaded with
+ * the source's profile, including a source that has stopped loading partially, for which the clone is re-loaded
+ * without one. Clone targets are excluded from rule-driven assignment
+ * ({@link DruidCluster#getManagedHistoricals()}), so this duty is the only thing that can correct them.
*/
public class CloneHistoricals implements CoordinatorDuty
{
@@ -103,10 +112,13 @@ public DruidCoordinatorRuntimeParams run(DruidCoordinatorRuntimeParams params)
final Set
+ * If the target server does not have the segment, it should be loaded. If the target server has the segment but with
+ * a different partial load profile than the source server, it should also be loaded.
+ *
+ * The two conditions are separate because a null profile does not identify a missing replica: a target that does not
+ * have the segment and a target holding it as a regular full load both project no profile at all.
+ */
+ private boolean shouldLoadSegmentOnTargetServer(
+ DataSegment segment,
+ @Nullable PartialLoadProfile sourceProfile,
+ ServerHolder targetServer,
+ Set
+ * If the segment on serverA is a partial load, the partial loadprofile is used to create the request to load
+ * the segment on serverB, ensuring that an equivalent partial load is loaded on serverB.
*/
private boolean moveSegment(DataSegment segment, ServerHolder serverA, ServerHolder serverB)
{
final String tier = serverA.getServer().getTier();
+
+ final PartialLoadProfile profile = serverA.getProjectedProfile(segment);
+ final PartialLoadProfile request = profile == null ? null : profile.asCloneRequest();
+
if (serverA.isLoadingSegment(segment)) {
// Cancel the load on serverA and load on serverB instead
if (serverA.cancelOperation(SegmentAction.LOAD, segment)) {
int loadedCountOnTier = replicaCountMap.get(segment.getId(), tier)
.loadedNotDropping();
if (loadedCountOnTier >= 1) {
- return replicateSegment(segment, serverB, null);
+ return replicateSegment(segment, serverB, request);
} else {
- return loadSegment(segment, serverB, null);
+ return loadSegment(segment, serverB, request);
}
}
// Could not cancel load, let the segment load on serverA and count it as unmoved
return false;
} else if (serverA.isServingSegment(segment)) {
- return loadQueueManager.moveSegment(segment, serverA, serverB);
+ return loadQueueManager.moveSegment(segment, serverA, serverB, request);
} else {
return false;
}
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 e6c6c5854516..87be5378926d 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
@@ -553,6 +553,93 @@ void testRuleSwapRewritesInfoFileOnDisk() throws Exception
);
}
+ @Test
+ void testFullLoadRequestReleasesRuleAndRewritesInfoFile() throws Exception
+ {
+ // An unwrapped load request for a segment held under a rule is the coordinator asking for the whole segment again.
+ // The rule's holds have to come off, so the pinned parts become evictable like any other virtual-storage full load,
+ // and the info file has to stop describing the rule so a restart doesn't reinstate it.
+ manager = makeManager(true, true);
+ manager.load(partialWrapperSegment(List.of(AGG_BUNDLE)));
+ Assertions.assertEquals(FINGERPRINT, manager.getRuleFingerprintForSegment(SEGMENT_ID));
+
+ manager.load(plainSegment());
+
+ Assertions.assertNull(
+ manager.getRuleFingerprintForSegment(SEGMENT_ID),
+ "a full load request must release the applied rule"
+ );
+ final File infoFile = new File(new File(cacheRoot, "info_dir"), SEGMENT_ID.toString());
+ final DataSegment onDisk = jsonMapper.readValue(infoFile, DataSegment.class);
+ Assertions.assertFalse(
+ PartialLoadSpec.detectPartialLoadSpec(onDisk.getLoadSpec()),
+ "info file on disk must no longer carry a partial-load wrapper"
+ );
+ }
+
+ @Test
+ void testFullLoadRequestFailsWhenTheInfoFileCannotBeRewritten() throws Exception
+ {
+ // The release has to reach disk or not happen at all: an unwrapped request announces as a full load either way, so
+ // a rule released only in memory would leave the coordinator recording a replica with no profile while a restart
+ // reinstates the rule. Failing the load instead sends the historical down its drop path, which cleans both up.
+ manager = makeManager(true, true);
+ manager.load(partialWrapperSegment(List.of(AGG_BUNDLE)));
+
+ final File infoDir = new File(cacheRoot, "info_dir");
+ Assertions.assertTrue(infoDir.setReadOnly(), "test setup must be able to make the info dir read-only");
+ try {
+ Assertions.assertThrows(
+ SegmentLoadingException.class,
+ () -> manager.load(plainSegment())
+ );
+ Assertions.assertEquals(
+ FINGERPRINT,
+ manager.getRuleFingerprintForSegment(SEGMENT_ID),
+ "the rule must still be applied when the release could not be persisted"
+ );
+ }
+ finally {
+ Assertions.assertTrue(infoDir.setWritable(true), "test teardown must restore write permission");
+ }
+ }
+
+ @Test
+ void testRestartAfterFullLoadRequestDoesNotReinstateRule() throws Exception
+ {
+ // The released rule has to stay released across a restart: bootstrap reads the rewritten info file, so it restores
+ // the partial layout that is still on disk without reapplying the rule, and announces no profile for it.
+ manager = makeManager(true, true);
+ manager.load(partialWrapperSegment(List.of(AGG_BUNDLE)));
+ manager.load(plainSegment());
+ manager.shutdown();
+ manager = null;
+
+ final SegmentLocalCacheManager restarted = makeManager(true, true);
+ try {
+ final DataSegment cached = restarted.getCachedSegments()
+ .stream()
+ .filter(s -> s.getId().equals(SEGMENT_ID))
+ .findFirst()
+ .orElse(null);
+ Assertions.assertNotNull(cached, "restarted historical must rediscover the segment via its info file");
+
+ final DataSegment bootstrapped = restarted.bootstrap(cached, SegmentLazyLoadFailCallback.NOOP);
+
+ Assertions.assertNull(
+ restarted.getRuleFingerprintForSegment(SEGMENT_ID),
+ "bootstrap must not reapply a rule that a full load request released"
+ );
+ Assertions.assertFalse(
+ bootstrapped instanceof DataSegmentAndLoadProfile,
+ "bootstrap must not announce a partial-load profile for a released rule"
+ );
+ }
+ finally {
+ restarted.shutdown();
+ }
+ }
+
@Test
void testDropClearsRule() throws Exception
{
@@ -840,6 +927,19 @@ private DataSegment compositeWrapperSegment(List