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 c70397cbcb7 fix: partial entry ephemeral cache release (#19872)
c70397cbcb7 is described below
commit c70397cbcb744fd7400aa124be9b3b4ae046f4d5
Author: Clint Wylie <[email protected]>
AuthorDate: Tue Aug 4 13:32:42 2026 -0700
fix: partial entry ephemeral cache release (#19872)
---
.../EmbeddedMSQProjectionPartialDownloadsTest.java | 190 +++++++++++++++++++++
.../druid/segment/loading/StorageLocation.java | 46 ++++-
.../druid/segment/loading/StorageLocationTest.java | 150 ++++++++++++++++
3 files changed, 380 insertions(+), 6 deletions(-)
diff --git
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/EmbeddedMSQProjectionPartialDownloadsTest.java
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/EmbeddedMSQProjectionPartialDownloadsTest.java
new file mode 100644
index 00000000000..a76c8517ef8
--- /dev/null
+++
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/EmbeddedMSQProjectionPartialDownloadsTest.java
@@ -0,0 +1,190 @@
+/*
+ * 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.msq;
+
+import org.apache.druid.common.utils.IdUtils;
+import org.apache.druid.data.input.impl.AggregateProjectionSpec;
+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.UniformGranularitySpec;
+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.Intervals;
+import org.apache.druid.java.util.common.granularity.Granularities;
+import org.apache.druid.query.QueryContexts;
+import org.apache.druid.query.aggregation.LongSumAggregatorFactory;
+import org.apache.druid.query.http.SqlTaskStatus;
+import org.apache.druid.server.metrics.LatchableEmitter;
+import org.apache.druid.server.metrics.StorageMonitor;
+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.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 test that ingests a projection segment and runs the exact GROUP
BY the projection serves through an
+ * MSQ task, so its GroupBy leaf processor acquires the projection bundle
(dependent on {@code __base}) under
+ * {@code AcquireMode.PARTIAL} and releases it at cursor close under the
ephemeral cache.
+ */
+class EmbeddedMSQProjectionPartialDownloadsTest extends EmbeddedClusterTestBase
+{
+ private static final String PROJECTION_NAME = "country_delta";
+ private static final long MONITOR_QUIESCE_TIMEOUT_MILLIS = 3_000L;
+ private static final String SELECT_SQL = "SELECT \"countryName\",
SUM(\"delta\") AS s FROM \"%s\" GROUP BY \"countryName\"";
+
+ private final EmbeddedBroker broker = new EmbeddedBroker();
+ private final EmbeddedIndexer indexer = new EmbeddedIndexer();
+ private final EmbeddedOverlord overlord = new EmbeddedOverlord();
+ private final EmbeddedHistorical historical = new EmbeddedHistorical();
+ private final EmbeddedCoordinator coordinator = new EmbeddedCoordinator();
+ private final EmbeddedRouter router = new EmbeddedRouter();
+
+ private EmbeddedMSQApis msqApis;
+
+ @Override
+ public EmbeddedDruidCluster createCluster()
+ {
+ indexer.setServerMemory(400_000_000)
+ .addProperty("druid.worker.capacity", "4")
+ .addProperty("druid.processing.numThreads", "3");
+
+ broker.setServerMemory(200_000_000);
+
+ coordinator.addProperty("druid.manager.segments.useIncrementalCache",
"always");
+ overlord.addProperty("druid.manager.segments.useIncrementalCache",
"always")
+ .addProperty("druid.manager.segments.pollDuration", "PT0.1s");
+
+ return EmbeddedDruidCluster
+ .withEmbeddedDerbyAndZookeeper()
+ .useLatchableEmitter()
+ .useDefaultTimeoutForLatchableEmitter(20)
+ .addCommonProperty("druid.storage.zip", "false")
+ .addCommonProperty("druid.indexer.task.buildV10", "true")
+ .addCommonProperty("druid.monitoring.emissionPeriod", "PT1s")
+ .addServer(coordinator)
+ .addServer(overlord)
+ .addServer(indexer)
+ .addServer(historical)
+ .addServer(broker)
+ .addServer(router);
+ }
+
+ @BeforeAll
+ void loadData() throws IOException
+ {
+ msqApis = new EmbeddedMSQApis(cluster, overlord);
+ dataSource = "projection-partial-" + IdUtils.getRandomId();
+ ingestSegmentWithProjection();
+ }
+
+ @Override
+ protected void refreshDatasourceName()
+ {
+ // Keep the datasource stable: it is ingested once in loadData() before
all tests.
+ }
+
+ @Test
+ void testProjectionServedGroupByDoesNotCrashWorker()
+ {
+ final LatchableEmitter emitter = indexer.latchableEmitter();
+
+ emitter.awaitMetricQuiescent(StorageMonitor.VSF_READ_COUNT,
MONITOR_QUIESCE_TIMEOUT_MILLIS);
+ emitter.flush();
+
+ final SqlTaskStatus status = msqApis.submitTaskSql(
+ Map.of(QueryContexts.USE_PROJECTION, PROJECTION_NAME),
+ SELECT_SQL,
+ dataSource
+ );
+ cluster.callApi().waitForTaskToSucceed(status.getTaskId(), overlord);
+
+ // Confirm the partial-load path actually engaged: on-demand deep-storage
range reads. The worker's StorageMonitor
+ // only emits while the ephemeral task is alive, so the events land during
the query; waitForEventAggregate also
+ // considers events already emitted since the last flush and fails the
test on timeout if none ever arrived.
+ emitter.waitForEventAggregate(
+ matcher -> matcher.hasMetricName(StorageMonitor.VSF_READ_COUNT),
+ aggregate -> aggregate.hasSumAtLeast(1L)
+ );
+ }
+
+ private void ingestSegmentWithProjection() throws IOException
+ {
+ final File tmpDir = cluster.getTestFolder().newFolder();
+ final File inputFile = new File(tmpDir, "projection-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 AggregateProjectionSpec projection =
+ AggregateProjectionSpec.builder(PROJECTION_NAME)
+ .groupingColumns(new
StringDimensionSchema("countryName"))
+ .aggregators(new
LongSumAggregatorFactory("sumDelta", "delta"))
+ .build();
+
+ final UniformGranularitySpec granularitySpec = new UniformGranularitySpec(
+ Granularities.HOUR,
+ Granularities.NONE,
+ false,
+ 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))
+ .withGranularity(granularitySpec)
+ .withDimensions(
+ new StringDimensionSchema("channel"),
+ new StringDimensionSchema("countryName"),
+ new LongDimensionSchema("delta")
+ )
+ .withProjections(List.of(projection))
+ )
+ .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/StorageLocation.java
b/server/src/main/java/org/apache/druid/segment/loading/StorageLocation.java
index 49fc135a9a8..6330f20cb4c 100644
--- a/server/src/main/java/org/apache/druid/segment/loading/StorageLocation.java
+++ b/server/src/main/java/org/apache/druid/segment/loading/StorageLocation.java
@@ -536,6 +536,7 @@ public class StorageLocation
{
lock.writeLock().lock();
try {
+ final WeakCacheEntry[] evicted = new WeakCacheEntry[1];
weakCacheEntries.computeIfPresent(
id,
(cacheEntryIdentifier, weakCacheEntry) -> {
@@ -544,19 +545,40 @@ public class StorageLocation
}
final boolean isMounted = weakCacheEntry.cacheEntry.isMounted();
unlinkWeakEntry(weakCacheEntry);
- weakCacheEntry.unmount();
if (isMounted) {
weakStats.getAndUpdate(s ->
s.evict(weakCacheEntry.cacheEntry.getSize()));
}
+ evicted[0] = weakCacheEntry;
return null;
}
);
+ unmountEvictedWeakEntry(evicted[0]);
}
finally {
lock.writeLock().unlock();
}
}
+ /**
+ * Fire {@link WeakCacheEntry#unmount()} for an entry that has just been
removed from {@link #weakCacheEntries}.
+ * <p>
+ * MUST be called <em>after</em> the enclosing {@link
Map#computeIfPresent}/{@link Map#computeIfAbsent} callback has
+ * returned, never from inside it. Unmounting a partial bundle entry drains
its {@link Phaser}, whose termination
+ * cascades into {@code PartialSegmentBundleCacheEntry.doActualUnmount} ->
parent bundle hold release ->
+ * {@code weakCacheEntries.computeIfPresent} for a <em>different</em> key.
Structurally modifying
+ * {@link #weakCacheEntries} from within another entry's compute callback
trips HashMap's fail-fast check
+ * (a {@link java.util.ConcurrentModificationException}). Removing the entry
first, then unmounting, keeps the
+ * cascade's map mutations sequential. Callers hold the write lock across
both steps so no reserve/re-mount can race
+ * the not-yet-drained phaser.
+ */
+ @GuardedBy("lock")
+ private void unmountEvictedWeakEntry(@Nullable WeakCacheEntry evicted)
+ {
+ if (evicted != null) {
+ evicted.unmount();
+ }
+ }
+
/**
* Creates a release runnable for a {@link WeakCacheEntry} that handles
immediate eviction when configured.
* If {@link #areWeakEntriesEphemeral} is true and there are no more holds
after releasing, the entry is immediately
@@ -578,6 +600,7 @@ public class StorageLocation
lock.writeLock().lock();
try {
+ final WeakCacheEntry[] evicted = new WeakCacheEntry[1];
weakCacheEntries.computeIfPresent(
weakEntry.cacheEntry.getId(),
(cacheEntryIdentifier, weakCacheEntry) -> {
@@ -587,16 +610,17 @@ public class StorageLocation
if ((isNewEntry && !isMounted)
|| (areWeakEntriesEphemeral && !weakCacheEntry.isHeld())) {
unlinkWeakEntry(weakCacheEntry);
- weakCacheEntry.unmount(); // call even if never mounted, to
terminate the phaser
if (isMounted) {
weakStats.getAndUpdate(s ->
s.evict(weakCacheEntry.cacheEntry.getSize()));
}
+ evicted[0] = weakCacheEntry;
return null;
} else {
return weakCacheEntry;
}
}
);
+ unmountEvictedWeakEntry(evicted[0]);
}
finally {
lock.writeLock().unlock();
@@ -739,6 +763,7 @@ public class StorageLocation
private void unmountReclaimed(ReclaimResult reclaimResult)
{
if (reclaimResult != null) {
+ final List<WeakCacheEntry> toUnmount = new ArrayList<>();
for (WeakCacheEntry removed : reclaimResult.getEvictions()) {
weakCacheEntries.computeIfAbsent(
removed.cacheEntry.getId(),
@@ -747,13 +772,16 @@ public class StorageLocation
weakStats.getAndUpdate(s ->
s.evict(removed.cacheEntry.getSize()));
// .. but make sure the same identifier wasn't moved to a static
load before we actually unmount it
if (!staticCacheEntries.containsKey(cacheEntryIdentifier)) {
- removed.unmount();
+ toUnmount.add(removed);
weakStats.getAndUpdate(WeakStats::unmount);
}
return null;
}
);
}
+ for (WeakCacheEntry removed : toUnmount) {
+ unmountEvictedWeakEntry(removed);
+ }
}
}
@@ -839,11 +867,17 @@ public class StorageLocation
entry.unmount();
}
staticCacheEntries.clear();
- while (head != null) {
- head.unmount();
- head = head.next;
+ final List<WeakCacheEntry> weakToUnmount = new ArrayList<>();
+ for (WeakCacheEntry entry = head; entry != null; entry = entry.next) {
+ weakToUnmount.add(entry);
}
+ head = null;
+ tail = null;
+ hand = null;
weakCacheEntries.clear();
+ for (WeakCacheEntry entry : weakToUnmount) {
+ entry.unmount();
+ }
}
finally {
lock.writeLock().unlock();
diff --git
a/server/src/test/java/org/apache/druid/segment/loading/StorageLocationTest.java
b/server/src/test/java/org/apache/druid/segment/loading/StorageLocationTest.java
index 433a5b414bd..b3b0f32d914 100644
---
a/server/src/test/java/org/apache/druid/segment/loading/StorageLocationTest.java
+++
b/server/src/test/java/org/apache/druid/segment/loading/StorageLocationTest.java
@@ -33,6 +33,8 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import javax.annotation.Nullable;
+import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
@@ -582,6 +584,93 @@ class StorageLocationTest
Assertions.assertEquals(0, location.getWeakStats().getHoldBytes());
}
+ @Test
+ public void
testEphemeralWeakEntryUnmountCascadeDoesNotThrowConcurrentModification()
+ {
+ final StorageLocation location = new StorageLocation(tempDir, 10_000L,
null);
+ location.setAreWeakEntriesEphemeral(true);
+
+ // Parent entry (e.g. a __base bundle), held by the child for its lifetime
and evicted only when the child unmounts.
+ final CascadingUnmountCacheEntry parent = new
CascadingUnmountCacheEntry("parent", 100L, null);
+ final StorageLocation.ReservationHold<?> parentHold =
+ location.addWeakReservationHold(parent.getId(), () -> parent);
+ Assertions.assertNotNull(parentHold);
+ parent.mount(location);
+
+ // Child entry (a partial bundle) whose unmount() releases the parent's
cache hold, re-entering StorageLocation.
+ final CascadingUnmountCacheEntry child = new
CascadingUnmountCacheEntry("child", 100L, parentHold);
+ final StorageLocation.ReservationHold<?> childHold =
+ location.addWeakReservationHold(child.getId(), () -> child);
+ Assertions.assertNotNull(childHold);
+ child.mount(location);
+
+ // Releasing the child's last hold evicts it (ephemeral) and fires
child.unmount() -> parentHold.close() -> parent
+ // eviction, all under the location write lock. Before the fix this threw
ConcurrentModificationException.
+ Assertions.assertDoesNotThrow(childHold::close);
+
+ Assertions.assertTrue(child.wasUnmounted());
+ Assertions.assertTrue(parent.wasUnmounted());
+ Assertions.assertNull(location.getCacheEntry(child.getId()));
+ Assertions.assertNull(location.getCacheEntry(parent.getId()));
+ }
+
+ @Test
+ public void
testRemoveUnheldWeakEntryUnmountCascadeDoesNotThrowConcurrentModification()
+ {
+ final StorageLocation location = new StorageLocation(tempDir, 10_000L,
null);
+
+ // The parent is deliberately NOT mounted: on a non-ephemeral location,
releasing the child's hold on it removes it
+ // via the `isNewEntry && !isMounted` branch of
createWeakEntryReleaseRunnable, and that removal (re-entering
+ // weakCacheEntries for the parent key) is what reproduces the cascade. Do
NOT add parent.mount() here — a mounted,
+ // non-ephemeral parent is never removed on hold release, so no nested map
mutation occurs and the test would pass
+ // even against the buggy code.
+ final CascadingUnmountCacheEntry parent = new
CascadingUnmountCacheEntry("parent", 100L, null);
+ final StorageLocation.ReservationHold<?> parentHold =
+ location.addWeakReservationHold(parent.getId(), () -> parent);
+ Assertions.assertNotNull(parentHold);
+
+ // Child registered WITHOUT a hold (the bootstrap reserveWeak path that
removeUnheldWeakEntry cleans up).
+ final CascadingUnmountCacheEntry child = new
CascadingUnmountCacheEntry("child", 100L, parentHold);
+ Assertions.assertTrue(location.reserveWeak(child));
+ child.mount(location);
+
+ Assertions.assertDoesNotThrow(() ->
location.removeUnheldWeakEntry(child.getId()));
+
+ Assertions.assertTrue(child.wasUnmounted());
+ Assertions.assertTrue(parent.wasUnmounted());
+ Assertions.assertNull(location.getCacheEntry(child.getId()));
+ Assertions.assertNull(location.getCacheEntry(parent.getId()));
+ }
+
+ @Test
+ public void testReclaimUnmountCascadeDoesNotThrowConcurrentModification()
+ {
+ // Small location so the filler reservation forces reclaim of the child
(parent is held, so reclaim skips it).
+ final StorageLocation location = new StorageLocation(tempDir, 100L, null);
+
+ // As in testRemoveUnheld..., the parent is deliberately NOT mounted so
that releasing the child's hold on it
+ // removes it (the `isNewEntry && !isMounted` branch), driving the nested
weakCacheEntries mutation. Adding
+ // parent.mount() would leave the parent in the map on release and defang
the reproduction.
+ final CascadingUnmountCacheEntry parent = new
CascadingUnmountCacheEntry("parent", 40L, null);
+ final StorageLocation.ReservationHold<?> parentHold =
+ location.addWeakReservationHold(parent.getId(), () -> parent);
+ Assertions.assertNotNull(parentHold);
+
+ // Child registered unheld and mounted, sole holder of the parent's cache
hold; it is the reclaim target.
+ final CascadingUnmountCacheEntry child = new
CascadingUnmountCacheEntry("child", 40L, parentHold);
+ Assertions.assertTrue(location.reserveWeak(child));
+ child.mount(location);
+
+ final CascadingUnmountCacheEntry filler = new
CascadingUnmountCacheEntry("filler", 40L, null);
+ Assertions.assertDoesNotThrow(() -> location.reserveWeak(filler));
+
+ Assertions.assertTrue(child.wasUnmounted());
+ Assertions.assertTrue(parent.wasUnmounted());
+ Assertions.assertNull(location.getCacheEntry(child.getId()));
+ Assertions.assertNull(location.getCacheEntry(parent.getId()));
+ Assertions.assertTrue(location.isWeakReserved(filler.getId()));
+ }
+
@SuppressWarnings({"GuardedBy", "FieldAccessNotGuarded"})
private void verifyLoc(long maxSize, StorageLocation loc)
{
@@ -759,6 +848,67 @@ class StorageLocationTest
}
}
+ /**
+ * A {@link CacheEntry} whose {@link #unmount()} optionally closes another
{@link Closeable} (e.g. a parent bundle's
+ * {@link StorageLocation.ReservationHold}), reproducing the partial-load
cascade where unmounting one weak entry
+ * re-enters {@link StorageLocation} to release a hold on a different weak
entry.
+ */
+ private static final class CascadingUnmountCacheEntry implements CacheEntry
+ {
+ private final StringCacheIdentifier id;
+ private final long size;
+ @Nullable
+ private final Closeable onUnmount;
+ private boolean mounted = false;
+ private boolean unmounted = false;
+
+ private CascadingUnmountCacheEntry(String id, long size, @Nullable
Closeable onUnmount)
+ {
+ this.id = new StringCacheIdentifier(id);
+ this.size = size;
+ this.onUnmount = onUnmount;
+ }
+
+ @Override
+ public StringCacheIdentifier getId()
+ {
+ return id;
+ }
+
+ @Override
+ public long getSize()
+ {
+ return size;
+ }
+
+ @Override
+ public boolean isMounted()
+ {
+ return mounted;
+ }
+
+ @Override
+ public void mount(StorageLocation location)
+ {
+ mounted = true;
+ }
+
+ @Override
+ public void unmount()
+ {
+ unmounted = true;
+ mounted = false;
+ if (onUnmount != null) {
+ CloseableUtils.closeAndWrapExceptions(onUnmount);
+ }
+ }
+
+ private boolean wasUnmounted()
+ {
+ return unmounted;
+ }
+ }
+
private static final class TestResizableCacheEntry implements
ResizableCacheEntry
{
private final StringCacheIdentifier id;
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]