This is an automated email from the ASF dual-hosted git repository.
capistrant 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 b824330ba15 fix: Fix clone historicals and inter-historical move
handling of partial load profiles (#19843)
b824330ba15 is described below
commit b824330ba153ed4cfbc1785da6a826dd74faba44
Author: Lucas Capistrant <[email protected]>
AuthorDate: Tue Aug 4 17:05:23 2026 -0500
fix: Fix clone historicals and inter-historical move handling of partial
load profiles (#19843)
Segment moves and Historical cloning did not have proper handling for
partial load profiles. When a segment should have been being loaded partially,
it was not.
---
.../server/PartialLoadHistoricalCloningTest.java | 329 ++++++++++++++++++++
.../segment/loading/SegmentLocalCacheManager.java | 72 ++++-
.../coordination/SegmentChangeRequestLoad.java | 10 +-
.../druid/server/coordinator/ServerHolder.java | 18 ++
.../server/coordinator/duty/CloneHistoricals.java | 59 +++-
.../coordinator/loading/PartialLoadProfile.java | 12 +
.../loading/SegmentLoadQueueManager.java | 10 +-
.../loading/StrategicSegmentAssigner.java | 13 +-
...egmentLocalCacheManagerPartialRuleLoadTest.java | 100 +++++++
.../coordinator/duty/CloneHistoricalsTest.java | 332 +++++++++++++++++++++
.../loading/PartialLoadProfileTest.java | 21 ++
.../StrategicSegmentAssignerPartialTest.java | 83 ++++++
12 files changed, 1035 insertions(+), 24 deletions(-)
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 00000000000..4ab23c6814e
--- /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.
+ * <p>
+ * 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 7764c62a153..fdf919a65d6 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 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
}
/**
- * 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 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
location.getPath()
);
}
- writePartialInfoFile(dataSegment);
+ rewriteInfoFile(dataSegment);
partial.setOnUnmount(() -> deleteSegmentInfoFile(dataSegment));
return new ReservedPartial(partial, location, hold);
}
@@ -1041,7 +1041,7 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
// 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(
@@ -1350,15 +1350,21 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
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);
}
}
}
@@ -1438,7 +1444,7 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
reapplyRuleFromInfoFile(dataSegment, partial);
loadedProfile = PartialLoadProfile.forLoaded(
dataSegment.getLoadSpec(),
- (String) dataSegment.getLoadSpec().get("fingerprint"),
+ (String)
dataSegment.getLoadSpec().get(PartialLoadSpec.FINGERPRINT_FIELD),
partial.getRealizedBytes()
);
}
@@ -1538,6 +1544,48 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
}
}
+ /**
+ * 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}.
+ * <p>
+ * 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.
+ * <p>
+ * 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 c6b5f4ac290..d6f4a04289e 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 class SegmentChangeRequestLoad implements
DataSegmentChangeRequest
final Map<String, Object> loadSpec = segment.getLoadSpec();
if (PartialLoadSpec.detectPartialLoadSpec(loadSpec)) {
// Historical didn't wrap, treat as full-fallback: fingerprint from the
loadSpec, loadedBytes = full size.
- return new SegmentChangeRequestLoad(segment, (String)
loadSpec.get("fingerprint"), segment.getSize());
+ return new SegmentChangeRequestLoad(
+ segment,
+ (String) loadSpec.get(PartialLoadSpec.FINGERPRINT_FIELD),
+ segment.getSize()
+ );
}
if (PartialLoadSpec.hasPartialTypePrefix(loadSpec)) {
// Type name claims partial-load but the wire form is malformed, the
PartialLoadSpec subtype's @JsonProperty
@@ -85,8 +89,8 @@ public class SegmentChangeRequestLoad implements
DataSegmentChangeRequest
+ "announcing as a regular load.",
segment.getId(),
loadSpec.get("type"),
- loadSpec.get("fingerprint"),
- loadSpec.get("delegate")
+ loadSpec.get(PartialLoadSpec.FINGERPRINT_FIELD),
+ loadSpec.get(PartialLoadSpec.DELEGATE_FIELD)
);
}
return new SegmentChangeRequestLoad(segment);
diff --git
a/server/src/main/java/org/apache/druid/server/coordinator/ServerHolder.java
b/server/src/main/java/org/apache/druid/server/coordinator/ServerHolder.java
index 2b9362e8274..0e137caed14 100644
--- a/server/src/main/java/org/apache/druid/server/coordinator/ServerHolder.java
+++ b/server/src/main/java/org/apache/druid/server/coordinator/ServerHolder.java
@@ -468,6 +468,24 @@ public class ServerHolder implements
Comparable<ServerHolder>
return inFlightProfiles.get(segment);
}
+ /**
+ * The {@link PartialLoadProfile} this server is expected to hold {@code
segment} under once its queued operations
+ * finish: the profile of an in-flight load if one is queued, else the
profile announced for the loaded replica.
+ * Returns null when the replica is (or is becoming) a regular full load,
including an in-flight load that carries
+ * no profile.
+ * <p>
+ * 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 c12193c9b0e..902ed31a573 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.DruidCluster;
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 javax.annotation.Nullable;
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 @@ import java.util.stream.Collectors;
* 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.
+ * <p>
+ * 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 class CloneHistoricals implements CoordinatorDuty
final Set<DataSegment> sourceProjectedSegments =
sourceServer.getProjectedSegments();
final Set<DataSegment> targetProjectedSegments =
targetServer.getProjectedSegments();
- // Load any segments missing in the clone target.
+ // Load any segment that the clone target is missing, or that it holds
under a different partial-load profile
+ // than the source. Segment identity alone can't tell those apart: two
replicas of the same segment id may hold
+ // different parts of it.
for (DataSegment segment : sourceProjectedSegments) {
- if (!targetProjectedSegments.contains(segment)) {
- loadSegmentOnTargetServer(segment, targetServer, params);
+ final PartialLoadProfile sourceProfile =
sourceServer.getProjectedProfile(segment);
+ if (shouldLoadSegmentOnTargetServer(segment, sourceProfile,
targetServer, targetProjectedSegments)) {
+ loadSegmentOnTargetServer(segment, sourceProfile, targetServer,
params);
}
}
@@ -124,8 +136,13 @@ public class CloneHistoricals implements CoordinatorDuty
return params;
}
+ /**
+ * Queues a load of {@code segment} on the clone target, asking for the same
parts of the segment that the source
+ * holds. A null {@code sourceProfile} means the source holds the whole
segment, which is the regular full load.
+ */
private void loadSegmentOnTargetServer(
DataSegment segment,
+ @Nullable PartialLoadProfile sourceProfile,
ServerHolder targetServer,
DruidCoordinatorRuntimeParams params
)
@@ -141,7 +158,12 @@ public class CloneHistoricals implements CoordinatorDuty
rowKey.and(Dimension.DESCRIPTION, "Segment not found in metadata
cache"),
1L
);
- } else if (loadQueueManager.loadSegment(loadableSegment, targetServer,
SegmentAction.LOAD)) {
+ } else if (loadQueueManager.loadSegment(
+ loadableSegment,
+ targetServer,
+ SegmentAction.LOAD,
+ sourceProfile == null ? null : sourceProfile.asCloneRequest()
+ )) {
params.getCoordinatorStats().add(
Stats.Segments.ASSIGNED_TO_CLONE,
rowKey.build(),
@@ -150,6 +172,12 @@ public class CloneHistoricals implements CoordinatorDuty
}
}
+ @Nullable
+ private static String fingerprintOf(@Nullable PartialLoadProfile profile)
+ {
+ return profile == null ? null : profile.fingerprint();
+ }
+
private void dropSegmentFromTargetServer(
DataSegment segment,
ServerHolder targetServer,
@@ -234,4 +262,27 @@ public class CloneHistoricals implements CoordinatorDuty
return newStatusMap;
}
+
+ /**
+ * Determine whether a segment should be loaded on the target server.
+ * <p>
+ * 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.
+ * <p>
+ * 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<DataSegment> targetProjectedSegments
+ )
+ {
+ if (!targetProjectedSegments.contains(segment)) {
+ return true;
+ }
+ final PartialLoadProfile targetProfile =
targetServer.getProjectedProfile(segment);
+ return !Objects.equals(fingerprintOf(sourceProfile),
fingerprintOf(targetProfile));
+ }
}
diff --git
a/server/src/main/java/org/apache/druid/server/coordinator/loading/PartialLoadProfile.java
b/server/src/main/java/org/apache/druid/server/coordinator/loading/PartialLoadProfile.java
index 04e1f86f658..27defc7801c 100644
---
a/server/src/main/java/org/apache/druid/server/coordinator/loading/PartialLoadProfile.java
+++
b/server/src/main/java/org/apache/druid/server/coordinator/loading/PartialLoadProfile.java
@@ -93,6 +93,18 @@ public record PartialLoadProfile(
return intern(new PartialLoadProfile(wrappedLoadSpec, fingerprint,
loadedBytes));
}
+ /**
+ * This profile in request form, for asking another server for the same
partial load that produced it: a clone
+ * catching up with its source, or a move handing a replica to its
destination. The wrapped load spec and the
+ * fingerprint identify the request and carry over as they are; {@code
loadedBytes} is dropped, because a profile read
+ * back off a server carries the footprint that server realized, which
belongs to that server's announcement rather
+ * than to a request. Returns {@code this} when the profile is already a
request.
+ */
+ public PartialLoadProfile asCloneRequest()
+ {
+ return loadedBytes == null ? this : forRequest(wrappedLoadSpec,
fingerprint);
+ }
+
private static PartialLoadProfile intern(PartialLoadProfile profile)
{
return INTERNER.intern(profile);
diff --git
a/server/src/main/java/org/apache/druid/server/coordinator/loading/SegmentLoadQueueManager.java
b/server/src/main/java/org/apache/druid/server/coordinator/loading/SegmentLoadQueueManager.java
index 2db28f2b98a..346a0a6f8f6 100644
---
a/server/src/main/java/org/apache/druid/server/coordinator/loading/SegmentLoadQueueManager.java
+++
b/server/src/main/java/org/apache/druid/server/coordinator/loading/SegmentLoadQueueManager.java
@@ -105,10 +105,15 @@ public class SegmentLoadQueueManager
}
}
+ /**
+ * Moves the segment from serverA to serverB, optionally carrying the
partial-load profile that serverA holds it
+ * under so that serverB is asked for the same parts of the segment rather
than the whole of it.
+ */
public boolean moveSegment(
DataSegment segment,
ServerHolder serverA,
- ServerHolder serverB
+ ServerHolder serverB,
+ @Nullable PartialLoadProfile profile
)
{
final LoadQueuePeon peonA = serverA.getPeon();
@@ -117,7 +122,7 @@ public class SegmentLoadQueueManager
if (!serverA.startOperation(SegmentAction.MOVE_FROM, segment)) {
return false;
}
- if (!serverB.startOperation(SegmentAction.MOVE_TO, segment)) {
+ if (!serverB.startOperation(SegmentAction.MOVE_TO, segment, profile)) {
serverA.cancelOperation(SegmentAction.MOVE_FROM, segment);
return false;
}
@@ -132,6 +137,7 @@ public class SegmentLoadQueueManager
peonB.loadSegment(
segment,
SegmentAction.MOVE_TO,
+ profile,
success -> {
// Drop segment only if:
// (1) segment load was successful on serverB
diff --git
a/server/src/main/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssigner.java
b/server/src/main/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssigner.java
index 3fde88fdea6..72920c78d9d 100644
---
a/server/src/main/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssigner.java
+++
b/server/src/main/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssigner.java
@@ -180,26 +180,33 @@ public class StrategicSegmentAssigner implements
SegmentActionHandler
/**
* Moves the given segment from serverA to serverB.
+ * <p>
+ * 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 e6c6c585451..87be5378926 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 @@ class SegmentLocalCacheManagerPartialRuleLoadTest
);
}
+ @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 @@ class SegmentLocalCacheManagerPartialRuleLoadTest
.build();
}
+ /**
+ * The same segment as {@link #partialWrapperSegment}, but with the plain
deep-storage load spec the coordinator sends
+ * for a regular full load.
+ */
+ private DataSegment plainSegment()
+ {
+ return DataSegment.builder(SEGMENT_ID)
+ .shardSpec(NoneShardSpec.instance())
+ .loadSpec(Map.of("type", "local", "path",
DEEP_STORAGE_DIR.getAbsolutePath()))
+ .size(0)
+ .build();
+ }
+
/**
* A wrapper whose inner LoadSpec resolves via {@code LocalLoadSpec} against
a directory that holds no V10 file, so
* {@code openRangeReader()} returns {@code null}. Simulates the "backend
doesn't support range reads" case.
diff --git
a/server/src/test/java/org/apache/druid/server/coordinator/duty/CloneHistoricalsTest.java
b/server/src/test/java/org/apache/druid/server/coordinator/duty/CloneHistoricalsTest.java
new file mode 100644
index 00000000000..9904a319eb5
--- /dev/null
+++
b/server/src/test/java/org/apache/druid/server/coordinator/duty/CloneHistoricalsTest.java
@@ -0,0 +1,332 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.server.coordinator.duty;
+
+import org.apache.druid.client.DruidServer;
+import org.apache.druid.java.util.common.DateTimes;
+import org.apache.druid.java.util.common.Intervals;
+import org.apache.druid.segment.TestDataSource;
+import org.apache.druid.server.coordination.ServerType;
+import org.apache.druid.server.coordinator.CloneStatusManager;
+import org.apache.druid.server.coordinator.CoordinatorDynamicConfig;
+import org.apache.druid.server.coordinator.DruidCluster;
+import org.apache.druid.server.coordinator.DruidCoordinatorRuntimeParams;
+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.SegmentHolder;
+import org.apache.druid.server.coordinator.loading.SegmentLoadQueueManager;
+import org.apache.druid.server.coordinator.loading.TestLoadQueuePeon;
+import org.apache.druid.timeline.DataSegment;
+import org.apache.druid.timeline.SegmentId;
+import org.apache.druid.timeline.partition.NumberedShardSpec;
+import org.joda.time.Duration;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import javax.annotation.Nullable;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Verifies that {@link CloneHistoricals} reproduces the source historical's
load state on its clone target, including
+ * the {@link PartialLoadProfile} a partial-load rule resolved to. A clone
that receives the segment without the
+ * profile loads the whole segment and announces the full segment size, so the
two servers diverge in both on-disk
+ * footprint and reported {@code curr_size}. The duty therefore compares
replicas by partial-load fingerprint rather
+ * than by segment id, and threads the source's wrapped load spec into the
clone's load request.
+ */
+public class CloneHistoricalsTest
+{
+ private static final String TIER = "tier1";
+ private static final String SOURCE_HOST = "source_host:8083";
+ private static final String TARGET_HOST = "target_host:8083";
+
+ private static final String FP_REVENUE = "v1:deadbeefcafebabe";
+ private static final String FP_USERS = "v1:0123456789abcdef";
+
+ private static final long SEGMENT_SIZE = 1000L;
+ private static final long REALIZED_BYTES = 250L;
+
+ private SegmentLoadQueueManager loadQueueManager;
+ private CloneHistoricals duty;
+
+ @BeforeEach
+ public void setUp()
+ {
+ loadQueueManager = new SegmentLoadQueueManager(null, null);
+ duty = new CloneHistoricals(loadQueueManager, new CloneStatusManager());
+ }
+
+ @Test
+ public void testCloneLoadsSegmentWithTheSourcePartialLoadProfile()
+ {
+ final DataSegment segment = createSegment();
+ final ServerHolder source = createServer(SOURCE_HOST, segment,
loadedProfile(FP_REVENUE, "revenue"));
+ final ServerHolder target = createServer(TARGET_HOST);
+
+ runDuty(source, target, segment);
+
+ final PartialLoadProfile queued = peonOf(target).getProfileFor(segment);
+ Assertions.assertNotNull(queued, "Clone must be asked to load the same
parts as its source");
+ Assertions.assertEquals(FP_REVENUE, queued.fingerprint());
+ Assertions.assertEquals(loadedProfile(FP_REVENUE,
"revenue").wrappedLoadSpec(), queued.wrappedLoadSpec());
+ Assertions.assertNull(queued.loadedBytes(), "Outbound request profile must
not carry loadedBytes");
+ }
+
+ @Test
+ public void testCloneLoadsSegmentWithTheProfileOfAnInFlightSourceLoad()
+ {
+ // The source's own load is still queued, so the profile lives only on the
peon's in-flight holder. The clone
+ // must follow the state the source is heading towards, not the state it
is in.
+ final DataSegment segment = createSegment();
+ final PartialLoadProfile inFlight = requestProfile(FP_REVENUE, "revenue");
+
+ final TestLoadQueuePeon sourcePeon = new TestLoadQueuePeon();
+ sourcePeon.addInFlightHolder(
+ new SegmentHolder(segment, SegmentAction.LOAD, inFlight,
Duration.standardSeconds(10), null)
+ );
+ final ServerHolder source = new
ServerHolder(createDruidServer(SOURCE_HOST).toImmutableDruidServer(),
sourcePeon);
+ final ServerHolder target = createServer(TARGET_HOST);
+
+ runDuty(source, target, segment);
+
+ final PartialLoadProfile queued = peonOf(target).getProfileFor(segment);
+ Assertions.assertNotNull(queued, "Clone must follow an in-flight partial
load on the source");
+ Assertions.assertEquals(FP_REVENUE, queued.fingerprint());
+ }
+
+ @Test
+ public void testCloneReloadsWhenItsFingerprintDiffersFromTheSource()
+ {
+ // Both servers hold the segment, but under different rules. The segment
ids are identical, so only the
+ // fingerprint distinguishes them.
+ final DataSegment segment = createSegment();
+ final ServerHolder source = createServer(SOURCE_HOST, segment,
loadedProfile(FP_USERS, "users"));
+ final ServerHolder target = createServer(TARGET_HOST, segment,
loadedProfile(FP_REVENUE, "revenue"));
+
+ runDuty(source, target, segment);
+
+ final PartialLoadProfile queued = peonOf(target).getProfileFor(segment);
+ Assertions.assertNotNull(queued, "Clone holding a different set of parts
must be re-loaded");
+ Assertions.assertEquals(FP_USERS, queued.fingerprint());
+ }
+
+ @Test
+ public void testCloneReloadsAsFullLoadWhenSourceNoLongerLoadsPartially()
+ {
+ // Source moved off the partial-load rule and now holds the whole segment;
the clone must follow it back. The
+ // request goes out with no profile even though the clone is already
serving the segment: the historical releases
+ // the partial-load rule it holds the replica under when it receives an
unwrapped load request.
+ final DataSegment segment = createSegment();
+ final ServerHolder source = createServer(SOURCE_HOST, segment, null);
+ final ServerHolder target = createServer(TARGET_HOST, segment,
loadedProfile(FP_REVENUE, "revenue"));
+
+ runDuty(source, target, segment);
+
+ Assertions.assertTrue(
+ peonOf(target).getSegmentsToLoad().contains(segment),
+ "Clone must be re-loaded when the source stops loading partially"
+ );
+ Assertions.assertNull(
+ peonOf(target).getProfileFor(segment),
+ "A full-load source must not thread a profile to the clone"
+ );
+ Assertions.assertTrue(peonOf(target).getSegmentsToDrop().isEmpty());
+ }
+
+ @Test
+ public void testCloneWithAPartialLoadStillQueuedIsConvertedOnALaterRun()
+ {
+ // A partial load can be queued on top of a replica the clone already
serves under a different profile, which is how
+ // the historical is asked to fill in the missing parts in place. A
segment with an operation already queued cannot
+ // take another one, so the queued load is left to complete and the next
run converts the replica it produces.
+ final DataSegment segment = createSegment();
+ final ServerHolder source = createServer(SOURCE_HOST, segment, null);
+
+ final TestLoadQueuePeon targetPeon = new TestLoadQueuePeon();
+ targetPeon.addInFlightHolder(new SegmentHolder(
+ segment,
+ SegmentAction.LOAD,
+ requestProfile(FP_USERS, "users"),
+ Duration.standardSeconds(10),
+ null
+ ));
+ final DruidServer targetDruidServer = createDruidServer(TARGET_HOST);
+ targetDruidServer.addDataSegment(segment, loadedProfile(FP_REVENUE,
"revenue"));
+ final ServerHolder target = new
ServerHolder(targetDruidServer.toImmutableDruidServer(), targetPeon);
+
+ runDuty(source, target, segment);
+
+ Assertions.assertEquals(
+ requestProfile(FP_USERS, "users"),
+ targetPeon.getProfileFor(segment),
+ "The queued partial load must be left alone"
+ );
+ Assertions.assertTrue(targetPeon.getSegmentsToDrop().isEmpty());
+ }
+
+ @Test
+ public void testFullLoadSourceQueuesPlainLoadOnClone()
+ {
+ final DataSegment segment = createSegment();
+ final ServerHolder source = createServer(SOURCE_HOST, segment, null);
+ final ServerHolder target = createServer(TARGET_HOST);
+
+ runDuty(source, target, segment);
+
+
Assertions.assertTrue(peonOf(target).getSegmentsToLoad().contains(segment));
+ Assertions.assertNull(peonOf(target).getProfileFor(segment));
+ }
+
+ @Test
+ public void testNothingIsQueuedWhenCloneFingerprintMatchesTheSource()
+ {
+ final DataSegment segment = createSegment();
+ final ServerHolder source = createServer(SOURCE_HOST, segment,
loadedProfile(FP_REVENUE, "revenue"));
+ final ServerHolder target = createServer(TARGET_HOST, segment,
loadedProfile(FP_REVENUE, "revenue"));
+
+ runDuty(source, target, segment);
+
+ Assertions.assertTrue(peonOf(target).getSegmentsToLoad().isEmpty());
+ Assertions.assertTrue(peonOf(target).getSegmentsToDrop().isEmpty());
+ }
+
+ @Test
+ public void
testNothingIsQueuedWhenCloneFellBackToAFullDownloadOfTheSameRequest()
+ {
+ // A clone whose historical cannot honour partial downloads announces the
requested fingerprint with the full
+ // segment size as its footprint. The request was satisfied, so the duty
must leave it alone rather than
+ // re-queueing the load on every run.
+ final DataSegment segment = createSegment();
+ final ServerHolder source = createServer(SOURCE_HOST, segment,
loadedProfile(FP_REVENUE, "revenue"));
+ final ServerHolder target = createServer(
+ TARGET_HOST,
+ segment,
+ PartialLoadProfile.forLoaded(wrappedLoadSpec(FP_REVENUE, "revenue"),
FP_REVENUE, SEGMENT_SIZE)
+ );
+
+ runDuty(source, target, segment);
+
+ Assertions.assertTrue(peonOf(target).getSegmentsToLoad().isEmpty());
+ Assertions.assertTrue(peonOf(target).getSegmentsToDrop().isEmpty());
+ }
+
+ @Test
+ public void testSegmentMissingFromSourceIsDroppedFromClone()
+ {
+ final DataSegment segment = createSegment();
+ final ServerHolder source = createServer(SOURCE_HOST);
+ final ServerHolder target = createServer(TARGET_HOST, segment,
loadedProfile(FP_REVENUE, "revenue"));
+
+ runDuty(source, target, segment);
+
+
Assertions.assertTrue(peonOf(target).getSegmentsToDrop().contains(segment));
+ Assertions.assertTrue(peonOf(target).getSegmentsToLoad().isEmpty());
+ }
+
+ private void runDuty(ServerHolder source, ServerHolder target,
DataSegment... usedSegments)
+ {
+ final DruidCluster cluster = DruidCluster.builder().addTier(TIER, source,
target).build();
+ final DruidCoordinatorRuntimeParams params =
+ DruidCoordinatorRuntimeParams
+ .builder()
+ .withDruidCluster(cluster)
+ .withUsedSegments(usedSegments)
+ .withDynamicConfigs(
+ CoordinatorDynamicConfig.builder()
+ .withCloneServers(Map.of(TARGET_HOST,
SOURCE_HOST))
+ .build()
+ )
+ .build();
+
+ duty.run(params);
+ }
+
+ private static TestLoadQueuePeon peonOf(ServerHolder server)
+ {
+ return (TestLoadQueuePeon) server.getPeon();
+ }
+
+ private static DruidServer createDruidServer(String host)
+ {
+ return new DruidServer(host, host, null, 10L << 30, null,
ServerType.HISTORICAL, TIER, 0);
+ }
+
+ /**
+ * Creates a server holder that serves each of the given segments. A
non-null profile announces the segment as a
+ * partial load with that profile; a null profile announces it as a regular
full load.
+ */
+ private static ServerHolder createServer(String host, DataSegment segment,
@Nullable PartialLoadProfile profile)
+ {
+ final DruidServer server = createDruidServer(host);
+ server.addDataSegment(segment, profile);
+ return new ServerHolder(server.toImmutableDruidServer(), new
TestLoadQueuePeon());
+ }
+
+ private static ServerHolder createServer(String host)
+ {
+ return new ServerHolder(createDruidServer(host).toImmutableDruidServer(),
new TestLoadQueuePeon());
+ }
+
+ private static DataSegment createSegment()
+ {
+ return DataSegment
+ .builder(
+ SegmentId.of(
+ TestDataSource.WIKI,
+ Intervals.of("2024/2025"),
+ DateTimes.nowUtc().toString(),
+ new NumberedShardSpec(0, 0)
+ )
+ )
+ .loadSpec(Map.of("type", "local", "path", "/var/druid/segments/foo"))
+ .projections(List.of("revenue", "users"))
+ .size(SEGMENT_SIZE)
+ .build();
+ }
+
+ private static Map<String, Object> wrappedLoadSpec(String fingerprint,
String projection)
+ {
+ return Map.of(
+ "type", "partialProjection",
+ "delegate", Map.of("type", "local", "path", "/var/druid/segments/foo"),
+ "projections", List.of(projection),
+ "fingerprint", fingerprint
+ );
+ }
+
+ /**
+ * The profile shape a historical announces after completing a partial load:
the request it was given, plus the
+ * footprint it actually materialized.
+ */
+ private static PartialLoadProfile loadedProfile(String fingerprint, String
projection)
+ {
+ return PartialLoadProfile.forLoaded(wrappedLoadSpec(fingerprint,
projection), fingerprint, REALIZED_BYTES);
+ }
+
+ /**
+ * The profile shape the coordinator sends out with a load request: no
footprint is known yet.
+ */
+ private static PartialLoadProfile requestProfile(String fingerprint, String
projection)
+ {
+ return PartialLoadProfile.forRequest(wrappedLoadSpec(fingerprint,
projection), fingerprint);
+ }
+}
diff --git
a/server/src/test/java/org/apache/druid/server/coordinator/loading/PartialLoadProfileTest.java
b/server/src/test/java/org/apache/druid/server/coordinator/loading/PartialLoadProfileTest.java
index ce0f42b7c07..85ea1ae284b 100644
---
a/server/src/test/java/org/apache/druid/server/coordinator/loading/PartialLoadProfileTest.java
+++
b/server/src/test/java/org/apache/druid/server/coordinator/loading/PartialLoadProfileTest.java
@@ -115,6 +115,27 @@ public class PartialLoadProfileTest
Assertions.assertFalse(profile.wrappedLoadSpec().containsKey("extra"));
}
+ @Test
+ public void testAsCloneRequestDropsTheAnnouncedFootprint()
+ {
+ // The realized footprint belongs to the announcement of the server that
loaded the segment, not to the request the
+ // clone target or move destination is about to get. Everything that
identifies the request carries over as-is.
+ final PartialLoadProfile loaded = PartialLoadProfile.forLoaded(WRAPPED,
FINGERPRINT, 12345L);
+
+ final PartialLoadProfile request = loaded.asCloneRequest();
+
+ Assertions.assertNull(request.loadedBytes(), "a request carries no
realized footprint");
+ Assertions.assertEquals(WRAPPED, request.wrappedLoadSpec());
+ Assertions.assertEquals(FINGERPRINT, request.fingerprint());
+ }
+
+ @Test
+ public void testAsCloneRequestOfARequestIsItself()
+ {
+ final PartialLoadProfile request = PartialLoadProfile.forRequest(WRAPPED,
FINGERPRINT);
+ Assertions.assertSame(request, request.asCloneRequest());
+ }
+
@Test
public void testEquals()
{
diff --git
a/server/src/test/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssignerPartialTest.java
b/server/src/test/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssignerPartialTest.java
index e1cc1fb0d39..c5ef0280cf3 100644
---
a/server/src/test/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssignerPartialTest.java
+++
b/server/src/test/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssignerPartialTest.java
@@ -404,6 +404,73 @@ public class StrategicSegmentAssignerPartialTest
);
}
+ @Test
+ public void testMoveOfPartialReplicaCarriesProfileToDestination()
+ {
+ // Balancing a partial replica must move the same parts of the segment.
Without the profile the destination
+ // downloads the whole segment, and the reconciler then has to replace it
with a partial replica on a later run.
+ final DataSegment segment = createSegment();
+ final ServerHolder source = createDecommissioningServerWithLoaded(TIER1,
segment, profileForRevenue());
+ final ServerHolder destination = createServer(TIER1);
+ final DruidCluster cluster = DruidCluster.builder().addTier(TIER1, source,
destination).build();
+
+ final DruidCoordinatorRuntimeParams params = makeRuntimeParams(cluster,
segment);
+ final boolean moved = params.getSegmentAssigner().moveSegment(segment,
source, List.of(destination));
+
+ Assert.assertTrue(moved);
+ Assert.assertEquals(SegmentAction.MOVE_TO,
destination.getActionOnSegment(segment));
+ final PartialLoadProfile queued = ((TestLoadQueuePeon)
destination.getPeon()).getProfileFor(segment);
+ Assert.assertNotNull("Move destination should be asked for the same parts
the source holds", queued);
+ Assert.assertEquals(FP_REVENUE, queued.fingerprint());
+ }
+
+ @Test
+ public void testMoveOfInFlightPartialLoadCarriesProfileToDestination()
+ {
+ // The source's load has not completed yet, so the move cancels it and
loads on the destination instead. The
+ // profile lives on the peon's in-flight holder, and cancelling the
operation clears it from the source, so it
+ // has to be read before the cancellation.
+ final DataSegment segment = createSegment();
+ final TestLoadQueuePeon sourcePeon = new TestLoadQueuePeon();
+ sourcePeon.addInFlightHolder(new SegmentHolder(
+ segment,
+ SegmentAction.LOAD,
+ profileForRevenue(),
+ org.joda.time.Duration.standardSeconds(10),
+ null
+ ));
+ final ServerHolder source = new
ServerHolder(createDruidServer(TIER1).toImmutableDruidServer(), sourcePeon,
true);
+ final ServerHolder destination = createServer(TIER1);
+ final DruidCluster cluster = DruidCluster.builder().addTier(TIER1, source,
destination).build();
+
+ final DruidCoordinatorRuntimeParams params = makeRuntimeParams(cluster,
segment);
+ final boolean moved = params.getSegmentAssigner().moveSegment(segment,
source, List.of(destination));
+
+ Assert.assertTrue(moved);
+ final PartialLoadProfile queued = ((TestLoadQueuePeon)
destination.getPeon()).getProfileFor(segment);
+ Assert.assertNotNull("Cancelled in-flight partial load should be reissued
to the destination", queued);
+ Assert.assertEquals(FP_REVENUE, queued.fingerprint());
+ }
+
+ @Test
+ public void testMoveOfFullLoadReplicaCarriesNoProfile()
+ {
+ final DataSegment segment = createSegment();
+ final ServerHolder source = createDecommissioningServerWithLoaded(TIER1,
segment, null);
+ final ServerHolder destination = createServer(TIER1);
+ final DruidCluster cluster = DruidCluster.builder().addTier(TIER1, source,
destination).build();
+
+ final DruidCoordinatorRuntimeParams params = makeRuntimeParams(cluster,
segment);
+ final boolean moved = params.getSegmentAssigner().moveSegment(segment,
source, List.of(destination));
+
+ Assert.assertTrue(moved);
+ Assert.assertEquals(SegmentAction.MOVE_TO,
destination.getActionOnSegment(segment));
+ Assert.assertNull(
+ "Moving a regular full-load replica must not thread a profile",
+ ((TestLoadQueuePeon) destination.getPeon()).getProfileFor(segment)
+ );
+ }
+
private DruidCoordinatorRuntimeParams makeRuntimeParams(DruidCluster
cluster, DataSegment... segments)
{
return DruidCoordinatorRuntimeParams
@@ -453,6 +520,22 @@ public class StrategicSegmentAssignerPartialTest
return new ServerHolder(createDruidServer(tier).toImmutableDruidServer(),
new TestLoadQueuePeon(), true);
}
+ /**
+ * Creates a decommissioning server that already serves the given segment,
announced with {@code profile} when it is
+ * non-null. Decommissioning keeps the server out of its own
move-destination candidates, so the move has exactly
+ * one place to go.
+ */
+ private ServerHolder createDecommissioningServerWithLoaded(
+ String tier,
+ DataSegment segment,
+ @Nullable PartialLoadProfile profile
+ )
+ {
+ final DruidServer server = createDruidServer(tier);
+ server.addDataSegment(segment, profile);
+ return new ServerHolder(server.toImmutableDruidServer(), new
TestLoadQueuePeon(), true);
+ }
+
private static DataSegment createSegment()
{
return DataSegment
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]