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 1ed7baae5cb fix: more chill abandoned partial loads and safer deferred
eviction of weak held entries (#20073)
1ed7baae5cb is described below
commit 1ed7baae5cb858810f1e197ea1ec46680322874e
Author: Clint Wylie <[email protected]>
AuthorDate: Tue Aug 25 20:02:17 2026 -0700
fix: more chill abandoned partial loads and safer deferred eviction of weak
held entries (#20073)
---
.../segment/loading/SegmentLocalCacheManager.java | 95 +++++++----
.../druid/segment/loading/StorageLocation.java | 7 +-
...SegmentLocalCacheManagerPartialAcquireTest.java | 185 +++++++++++++++++++++
.../druid/segment/loading/StorageLocationTest.java | 43 ++++-
4 files changed, 294 insertions(+), 36 deletions(-)
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 d8179e3ed45..f441515e1e6 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
@@ -383,18 +383,30 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
@Override
public void storeInfoFile(final DataSegment segment) throws IOException
+ {
+ writeInfoFile(segment, false);
+ }
+
+ /**
+ * Internal info file writer, where {@code overwrite} can be set to force
writing the file to disk unconditionally.
+ *
+ * @return whether this call wrote the file
+ */
+ private boolean writeInfoFile(final DataSegment segment, final boolean
overwrite) throws IOException
{
final File segmentInfoCacheFile = getSegmentInfoFile(segment);
- if (!segmentInfoCacheFile.exists()) {
- FileUtils.mkdirp(segmentInfoCacheFile.getParentFile());
- FileUtils.writeAtomically(
- segmentInfoCacheFile,
- out -> {
- jsonMapper.writeValue(out, segment);
- return null;
- }
- );
+ if (!overwrite && segmentInfoCacheFile.exists()) {
+ return false;
}
+ FileUtils.mkdirp(getEffectiveInfoDir());
+ FileUtils.writeAtomically(
+ segmentInfoCacheFile,
+ out -> {
+ jsonMapper.writeValue(out, segment);
+ return null;
+ }
+ );
+ return true;
}
@Override
@@ -444,12 +456,7 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
*/
private void rewriteInfoFile(DataSegment segment) throws IOException
{
- final File segmentInfoCacheFile = getSegmentInfoFile(segment);
- FileUtils.mkdirp(getEffectiveInfoDir());
- FileUtils.writeAtomically(segmentInfoCacheFile, out -> {
- jsonMapper.writeValue(out, segment);
- return null;
- });
+ writeInfoFile(segment, true);
}
@Override
@@ -501,13 +508,8 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
try {
if (hold != null) {
// write the segment info file if it doesn't exist. this can
happen if we are loading after a drop
- final File segmentInfoCacheFile =
getSegmentInfoFile(dataSegment);
- if (!segmentInfoCacheFile.exists()) {
- FileUtils.mkdirp(getEffectiveInfoDir());
- FileUtils.writeAtomically(segmentInfoCacheFile, out -> {
- jsonMapper.writeValue(out, dataSegment);
- return null;
- });
+ if (writeInfoFile(dataSegment, false)) {
+ // if we wrote it, set the hook to clean it up too
hold.getEntry().setOnUnmount(() ->
deleteSegmentInfoFile(dataSegment));
}
@@ -654,7 +656,12 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
try {
reserved.metadata.mount(reserved.location);
}
- catch (IOException e) {
+ catch (IOException | RuntimeException e) {
+ // only fail if caller was expecting this to finish
+ if (holdHolder.isClosed()) {
+ log.debug(e, "Mount of segment[%s] failed after its
acquire was abandoned", dataSegment.getId());
+ return AcquireSegmentResult.empty();
+ }
throw DruidException.defensive(
e,
"Failed to mount partial metadata for segment[%s]",
@@ -667,12 +674,15 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
taskMetadataRef =
reserved.metadata.acquireMetadataReference();
}
catch (DruidException raceLost) {
- throw DruidException.defensive(
- raceLost,
- "Partial metadata for segment[%s] was dropped before %s
task could complete",
- dataSegment.getId(),
- fullDownload ? "full-download" : "lazy mount"
- );
+ // The entry was evicted between mounting it and pinning it.
What keeps it resident across that
+ // window is the hold in holdHolder, so an acquire that
still holds one is entitled to its segment
+ // and getting here means that guarantee has been broken so
rethrow
+ if (!holdHolder.isClosed()) {
+ throw raceLost;
+ }
+ // hold is release so we have an abandoned acquire; nothing
is waiting on this result, report
+ // the segment as unavailable rather than failing a query
that is already on its way down
+ return AcquireSegmentResult.empty();
}
try {
final PartialSegmentFileMapperV10 mapper =
reserved.metadata.getFileMapper();
@@ -950,6 +960,17 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
{
final ReservedPartial existing =
findExistingPartialWithHold(dataSegment.getId());
if (existing != null) {
+ if (!existing.metadata().isMounted()) {
+ // Restore the info file if it is missing
+ try {
+ if (writeInfoFile(dataSegment, false)) {
+ existing.metadata().setOnUnmount(() ->
deleteSegmentInfoFile(dataSegment));
+ }
+ }
+ catch (IOException e) {
+ log.warn(e, "Failed to restore info file for cached segment[%s]",
dataSegment.getId());
+ }
+ }
return existing;
}
return reservePartial(dataSegment, rangeReader);
@@ -2458,14 +2479,26 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
{
@GuardedBy("this")
private final Closer holds = Closer.create();
- @GuardedBy("this")
- private boolean closed = false;
+ /**
+ * Volatile rather than guarded, so {@link #isClosed} can be answered
without queueing behind a {@link #close}
+ * that is working through a hold-release cascade (location write lock,
unmount, unmap, file deletion).
+ */
+ private volatile boolean closed = false;
private HoldHolder(Closeable initialHold)
{
holds.register(initialHold);
}
+ /**
+ * Whether the {@link AcquireSegmentAction} these holds belong to has been
closed, i.e. whoever wanted the segment
+ * has stopped waiting for it and every hold taken so far is being (or has
been) released.
+ */
+ private boolean isClosed()
+ {
+ return closed;
+ }
+
private void add(Closeable hold)
{
final boolean alreadyClosed;
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 0c3e4cc8d77..5236aa19689 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
@@ -556,11 +556,14 @@ public class StorageLocation
weakCacheEntries.computeIfPresent(
weakEntry.cacheEntry.getId(),
(cacheEntryIdentifier, weakCacheEntry) -> {
+ if (weakCacheEntry != weakEntry || weakCacheEntry.isHeld()) {
+ // Someone else's entry, or someone else is still using ours;
either way, theirs to clean up.
+ return weakCacheEntry;
+ }
// If we never successfully mounted, go ahead and remove so we
don't have a dead entry.
// Furthermore, if evictImmediatelyOnHoldRelease is set, evict
on release if all holds are gone.
final boolean isMounted = weakCacheEntry.cacheEntry.isMounted();
- if ((isNewEntry && !isMounted)
- || (areWeakEntriesEphemeral && !weakCacheEntry.isHeld())) {
+ if ((isNewEntry && !isMounted) || areWeakEntriesEphemeral) {
unlinkWeakEntry(weakCacheEntry);
if (isMounted) {
weakStats.getAndUpdate(s ->
s.evict(weakCacheEntry.cacheEntry.getSize()));
diff --git
a/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialAcquireTest.java
b/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialAcquireTest.java
index ccc47cfb6d3..4a5be4714b3 100644
---
a/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialAcquireTest.java
+++
b/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialAcquireTest.java
@@ -22,6 +22,7 @@ package org.apache.druid.segment.loading;
import com.fasterxml.jackson.databind.InjectableValues;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.NamedType;
+import com.google.common.util.concurrent.ListenableFuture;
import org.apache.druid.data.input.InputRow;
import org.apache.druid.data.input.ListBasedInputRow;
import org.apache.druid.data.input.MapBasedInputRow;
@@ -37,6 +38,7 @@ import org.apache.druid.java.util.common.DateTimes;
import org.apache.druid.java.util.common.FileUtils;
import org.apache.druid.java.util.common.Intervals;
import org.apache.druid.java.util.common.granularity.Granularities;
+import org.apache.druid.java.util.common.io.Closer;
import org.apache.druid.java.util.emitter.EmittingLogger;
import org.apache.druid.math.expr.ExprMacroTable;
import org.apache.druid.query.aggregation.CountAggregatorFactory;
@@ -79,6 +81,7 @@ import org.junit.jupiter.api.extension.RegisterExtension;
import java.io.File;
import java.io.IOException;
+import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -962,6 +965,188 @@ class SegmentLocalCacheManagerPartialAcquireTest
);
}
+ @Test
+ void testAbandonedAcquireLeavesAConcurrentAcquiresEntryAlone()
+ throws ExecutionException, InterruptedException, IOException
+ {
+ final StorageLocation location = manager.getLocations().get(0);
+ final SegmentCacheEntryIdentifier id = new
SegmentCacheEntryIdentifier(SEGMENT_ID);
+
+ // Two acquires holding the same freshly reserved entry, neither of which
resolves its future, so nothing mounts
+ // it. The acquire that created the entry gives up first - a canceled or
timed out query, say.
+ final PartialSegmentMetadataCacheEntry entry;
+ final Closer acquires = Closer.create();
+ try {
+ // The first acquire reserves the entry; the second joins it. Both are
registered with the closer so a failed
+ // assertion cannot leak a hold into the shared manager fixture;
AcquireSegmentAction.close() is idempotent, so
+ // closing the first one early below is safe.
+ final AcquireSegmentAction creator = acquires.register(
+ manager.acquireSegment(partialSegment, AcquireMode.PARTIAL)
+ );
+ acquires.register(manager.acquireSegment(partialSegment,
AcquireMode.PARTIAL));
+ entry =
Assertions.assertInstanceOf(PartialSegmentMetadataCacheEntry.class,
location.getCacheEntry(id));
+ Assertions.assertFalse(entry.isMounted());
+
+ creator.close();
+ Assertions.assertSame(
+ entry,
+ location.getCacheEntry(id),
+ "the entry the second acquire is holding must survive the first
giving up"
+ );
+ }
+ finally {
+ acquires.close();
+ }
+ // The second acquire letting go does not remove it either - removal is
the creating hold's job - so it is left
+ // registered and unmounted, reclaimable, and reusable by the next acquire.
+ Assertions.assertSame(entry, location.getCacheEntry(id));
+ Assertions.assertFalse(entry.isMounted());
+
+ // A later acquire mounts that same entry and serves the segment from it.
+ try (AcquireSegmentAction action = manager.acquireSegment(partialSegment,
AcquireMode.PARTIAL)) {
+ final AcquireSegmentResult result = action.getSegmentFuture().get();
+ try (Segment segment =
result.getReferenceProvider().acquireReference().orElseThrow()) {
+ Assertions.assertEquals(SEGMENT_ID, segment.getId());
+ final TimeBoundaryInspector inspector =
segment.as(TimeBoundaryInspector.class);
+ Assertions.assertNotNull(inspector);
+ Assertions.assertEquals(TIME, inspector.getMinTime());
+ Assertions.assertEquals(TIME.plusMinutes(3), inspector.getMaxTime());
+ }
+ Assertions.assertSame(entry, location.getCacheEntry(id));
+ Assertions.assertTrue(entry.isMounted());
+ }
+ }
+
+ @Test
+ void testAbandonedAcquireWhoseMountFailsResolvesToAnUnavailableSegmentToo()
throws Exception
+ {
+ // Deep storage that can produce a range reader now but not serve a read
later: openRangeReader() checks the V10
+ // file at acquire time, and the test deletes it before the load task gets
to run.
+ final File vanishingStorage =
temporaryFolder.newFolder("vanishing_storage");
+ final File v10File = new File(vanishingStorage, IndexIO.V10_FILE_NAME);
+ Files.copy(new File(DEEP_STORAGE_DIR, IndexIO.V10_FILE_NAME).toPath(),
v10File.toPath());
+ final DataSegment vanishingSegment = DataSegment.builder(SEGMENT_ID)
+
.shardSpec(NoneShardSpec.instance())
+ .loadSpec(Map.of("type",
"local", "path", vanishingStorage.getAbsolutePath()))
+ .size(0)
+ .build();
+
+ final File gatedCacheRoot =
temporaryFolder.newFolder("gated_cache_mount_failure");
+ final SegmentLoaderConfig gatedConfig = SegmentLoaderConfig.builder()
+ .locations(new StorageLocationConfig(gatedCacheRoot, 1024L * 1024L *
1024L, null))
+ .virtualStorage(true)
+ .virtualStoragePartialDownloadsEnabled(true)
+ .virtualStorageUseVirtualThreads(false)
+ .virtualStorageLoadThreads(1)
+ .build();
+ final List<StorageLocation> storageLocations =
gatedConfig.toStorageLocations();
+ final StorageLoadingThreadPool gatedPool =
StorageLoadingThreadPool.createFromConfig(gatedConfig);
+ final SegmentLocalCacheManager gatedManager = new SegmentLocalCacheManager(
+ storageLocations,
+ gatedConfig,
+ gatedPool,
+ new LeastBytesUsedStorageLocationSelectorStrategy(storageLocations),
+ TestHelper.getTestIndexIO(jsonMapper, ColumnConfig.DEFAULT),
+ jsonMapper
+ );
+
+ final CountDownLatch atGate = new CountDownLatch(1);
+ final CountDownLatch openGate = new CountDownLatch(1);
+ try {
+ // (intentionally unused) local so errorprone's CheckReturnValue is
satisfied
+ @SuppressWarnings("unused")
+ ListenableFuture<?> unused = gatedPool.getExecutorService().submit(() ->
{
+ atGate.countDown();
+ return openGate.await(30, TimeUnit.SECONDS);
+ });
+ Assertions.assertTrue(atGate.await(30, TimeUnit.SECONDS), "loading
thread must reach the gate");
+
+ final ListenableFuture<AcquireSegmentResult> future;
+ try (AcquireSegmentAction action =
gatedManager.acquireSegment(vanishingSegment, AcquireMode.PARTIAL)) {
+ future = action.getSegmentFuture();
+ }
+ // The mount will now fail on its header read rather than rolling back
cleanly, so the loss surfaces from
+ // mount() instead of from the pin - which is no reason to fail a query
that has already given up.
+ Assertions.assertTrue(v10File.delete(), "test needs the deep-storage
file gone before the mount runs");
+ openGate.countDown();
+
+ final AcquireSegmentResult result = future.get(30, TimeUnit.SECONDS);
+ Assertions.assertTrue(
+ result.getReferenceProvider().acquireReference().isEmpty(),
+ "an abandoned acquire whose mount failed must resolve to an
unavailable segment"
+ );
+ }
+ finally {
+ openGate.countDown();
+ gatedManager.drop(vanishingSegment);
+ gatedManager.shutdown();
+ gatedPool.stop();
+ }
+ }
+
+ @Test
+ void testAbandonedAcquireResolvesToAnUnavailableSegmentRatherThanFailing()
throws Exception
+ {
+ // One fixed loading thread, so the test can hold the load task at a gate
while it abandons the acquire.
+ final File gatedCacheRoot = temporaryFolder.newFolder("gated_cache");
+ final SegmentLoaderConfig gatedConfig = SegmentLoaderConfig.builder()
+ .locations(new StorageLocationConfig(gatedCacheRoot, 1024L * 1024L *
1024L, null))
+ .virtualStorage(true)
+ .virtualStoragePartialDownloadsEnabled(true)
+ .virtualStorageUseVirtualThreads(false)
+ .virtualStorageLoadThreads(1)
+ .build();
+ final List<StorageLocation> storageLocations =
gatedConfig.toStorageLocations();
+ final StorageLoadingThreadPool gatedPool =
StorageLoadingThreadPool.createFromConfig(gatedConfig);
+ final SegmentLocalCacheManager gatedManager = new SegmentLocalCacheManager(
+ storageLocations,
+ gatedConfig,
+ gatedPool,
+ new LeastBytesUsedStorageLocationSelectorStrategy(storageLocations),
+ TestHelper.getTestIndexIO(jsonMapper, ColumnConfig.DEFAULT),
+ jsonMapper
+ );
+
+ final CountDownLatch atGate = new CountDownLatch(1);
+ final CountDownLatch openGate = new CountDownLatch(1);
+ try {
+ // (intentionally unused) local so errorprone's CheckReturnValue is
satisfied
+ @SuppressWarnings("unused")
+ ListenableFuture<?> unused = gatedPool.getExecutorService().submit(() ->
{
+ atGate.countDown();
+ return openGate.await(30, TimeUnit.SECONDS);
+ });
+ Assertions.assertTrue(atGate.await(30, TimeUnit.SECONDS), "loading
thread must reach the gate");
+
+ // Start the acquire (its load task queues behind the gate) and then
abandon it (like a canceled or timed
+ // out query does); closing the action releases the hold that was
keeping the reserved entry resident.
+ final ListenableFuture<AcquireSegmentResult> future;
+ try (AcquireSegmentAction action =
gatedManager.acquireSegment(partialSegment, AcquireMode.PARTIAL)) {
+ future = action.getSegmentFuture();
+ }
+ openGate.countDown();
+
+ // The task now mounts an entry the location no longer knows about, so
it can never pin it. Nothing is waiting
+ // on the result, and a segment that is not there is not a reason to
fail a query.
+ final AcquireSegmentResult result = future.get(30, TimeUnit.SECONDS);
+ Assertions.assertTrue(
+ result.getReferenceProvider().acquireReference().isEmpty(),
+ "an abandoned acquire must resolve to an unavailable segment"
+ );
+ Assertions.assertNull(
+ gatedManager.getLocations().get(0).getCacheEntry(new
SegmentCacheEntryIdentifier(SEGMENT_ID)),
+ "the abandoned entry must not be left behind in the cache"
+ );
+ }
+ finally {
+ openGate.countDown();
+ gatedManager.drop(partialSegment);
+ gatedManager.shutdown();
+ // shutdown() only stops the manager's own executor; the loading pool is
stopped through its lifecycle hook
+ gatedPool.stop();
+ }
+ }
+
/**
* Lay down the on-disk artifacts a previous process run would have left
behind in the given partial directory:
* a V10 header file and sparse-allocated container files for every
container the segment metadata declares. The
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 52e4c3ee489..b4b01f6dd21 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
@@ -584,6 +584,46 @@ class StorageLocationTest
Assertions.assertEquals(0, location.getWeakStats().getHoldBytes());
}
+ @Test
+ public void
testReleasingReserversHoldDoesNotEvictAnEntryAnotherHolderIsStillUsing()
+ {
+ final StorageLocation location = new StorageLocation(tempDir, 100L, null);
+ final UnmountTrackingCacheEntry entry = new UnmountTrackingCacheEntry("a",
10);
+
+ // One acquirer reserves the entry, a second takes its own hold on the
same entry, and then the first gives up
+ // before anything has managed to mount it
+ final StorageLocation.ReservationHold<?> reserver =
location.addWeakReservationHold(entry.getId(), () -> entry);
+ final StorageLocation.ReservationHold<?> other =
location.addWeakReservationHold(entry.getId(), () -> entry);
+ Assertions.assertNotNull(reserver);
+ Assertions.assertNotNull(other);
+ Assertions.assertFalse(entry.isMounted());
+
+ reserver.close();
+
+ // The second holder is still using the entry (e.g. mid-mount) and
evicting it out from under a hold
+ // is what the hold exists to prevent: a mount that completes to find its
entry unregistered rolls itself back,
+ // leaving the holder with a reference to an entry it was promised would
stay alive.
+ Assertions.assertSame(entry, location.getCacheEntry(entry.getId()));
+ Assertions.assertFalse(entry.unmountCalled);
+
+ // A later acquirer for this id reuses the registered entry rather than
registering a second one alongside it,
+ // which is what keeps the on-disk state two entries for the same id would
share owned by exactly one of them.
+ final UnmountTrackingCacheEntry replacement = new
UnmountTrackingCacheEntry("a", 10);
+ final StorageLocation.ReservationHold<?> reacquire =
+ location.addWeakReservationHold(entry.getId(), () -> replacement);
+ Assertions.assertNotNull(reacquire);
+ Assertions.assertSame(entry, reacquire.getEntry());
+ Assertions.assertFalse(replacement.unmountCalled);
+ reacquire.close();
+ Assertions.assertSame(entry, location.getCacheEntry(entry.getId()));
+
+ // Releasing that hold does not remove it either - removal is the creating
hold's job - so it is left registered
+ // and unmounted, for reclaim to take when the space is wanted.
+ other.close();
+ Assertions.assertSame(entry, location.getCacheEntry(entry.getId()));
+ Assertions.assertFalse(entry.unmountCalled);
+ }
+
@Test
public void
testEphemeralWeakEntryUnmountCascadeDoesNotThrowConcurrentModification()
{
@@ -822,9 +862,6 @@ class StorageLocationTest
}
}
- /**
- * A {@link CacheEntry} that tracks mount/unmount so tests can assert that
lifecycle hooks fired.
- */
private static final class UnmountTrackingCacheEntry implements CacheEntry
{
private final StringCacheIdentifier id;
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]