This is an automated email from the ASF dual-hosted git repository.
kfaraz 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 543256bd422 fix: loading pool callers decide when to permit (#19717)
543256bd422 is described below
commit 543256bd422f6f17a1ab3952d0aab538fc22f30b
Author: Clint Wylie <[email protected]>
AuthorDate: Wed Jul 22 00:45:37 2026 -0700
fix: loading pool callers decide when to permit (#19717)
Reworks `StorageLoadingThreadPool` to no longer bake the semaphore permits
into the executor,
and instead acquire the permits as close as possible to the actual deep
storage reads, combining
them was a mistake and it could at least in some scenarios lead to
deadlocks.
`SegmentRangeReader` is done with a wrapper that bakes the permit process
into the stream for
range reads; FilePopulator similarly wraps populate in an acquire, and the
complete segment load
path acquires directly when calling `loadInLocation`.
---
.../loading/PermitLimitedSegmentRangeReader.java | 86 ++++++
.../segment/loading/SegmentLocalCacheManager.java | 35 ++-
.../segment/loading/StorageLoadingThreadPool.java | 302 ++++++++-------------
.../StorageLocationVirtualStorageManager.java | 10 +-
.../PermitBoundedListeningExecutorServiceTest.java | 106 --------
.../PermitLimitedSegmentRangeReaderTest.java | 88 ++++++
.../loading/StorageLoadingThreadPoolTest.java | 85 ++++++
7 files changed, 413 insertions(+), 299 deletions(-)
diff --git
a/server/src/main/java/org/apache/druid/segment/loading/PermitLimitedSegmentRangeReader.java
b/server/src/main/java/org/apache/druid/segment/loading/PermitLimitedSegmentRangeReader.java
new file mode 100644
index 00000000000..f6edc4e8dde
--- /dev/null
+++
b/server/src/main/java/org/apache/druid/segment/loading/PermitLimitedSegmentRangeReader.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.segment.loading;
+
+import java.io.Closeable;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * A {@link SegmentRangeReader} decorator that bounds concurrent deep-storage
range reads: each {@link #readRange} call
+ * acquires an on-demand-load permit from {@link
StorageLoadingThreadPool#acquireLoadPermit()} and holds it for the
+ * lifetime of the returned stream (released on close), so the permit spans
exactly the wire transfer.
+ */
+public class PermitLimitedSegmentRangeReader implements SegmentRangeReader
+{
+ private final SegmentRangeReader delegate;
+ private final StorageLoadingThreadPool pool;
+
+ public PermitLimitedSegmentRangeReader(SegmentRangeReader delegate,
StorageLoadingThreadPool pool)
+ {
+ this.delegate = delegate;
+ this.pool = pool;
+ }
+
+ @Override
+ public InputStream readRange(String filename, long offset, long length)
throws IOException
+ {
+ final Closeable permit = pool.acquireLoadPermit();
+ try {
+ return new PermitReleasingInputStream(delegate.readRange(filename,
offset, length), permit);
+ }
+ catch (Throwable t) {
+ try {
+ permit.close();
+ }
+ catch (Exception e) {
+ t.addSuppressed(e);
+ }
+ throw t;
+ }
+ }
+
+ /**
+ * Releases the permit when the stream is closed (after the transfer
completes or the reader aborts). The permit's
+ * {@code close()} is idempotent, so a double-close of the stream
over-releases nothing.
+ */
+ private static final class PermitReleasingInputStream extends
FilterInputStream
+ {
+ private final Closeable permit;
+
+ PermitReleasingInputStream(InputStream in, Closeable permit)
+ {
+ super(in);
+ this.permit = permit;
+ }
+
+ @Override
+ public void close() throws IOException
+ {
+ try {
+ super.close();
+ }
+ finally {
+ permit.close();
+ }
+ }
+ }
+}
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 5059c932a03..ec99cd4c1c3 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
@@ -664,13 +664,15 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
// executor tasks. The entry's own mount-future dedup would
prevent the actual work from being duplicated,
// but the executor scheduling and timing capture would still be
wasted.
Suppliers.memoize(() -> {
- // Capture submit time on first invocation of
getSegmentFuture(), so waitTime measures the queue delay
- // until the executor picks up the task. loadTime then covers
mount (+ ensureAllDownloaded for the
- // full-download path).
+ // Capture submit time on first invocation of
getSegmentFuture(). loadTime then covers mount
+ // (+ ensureAllDownloaded for the full-download path).
final long submitNanos = System.nanoTime();
return
virtualStorageLoadingThreadPool.getExecutorService().submit(() -> {
- // The executor bounds concurrency itself (permit acquired
inside the task on the worker thread), so
- // waitNanos measures both the queue delay and any permit wait
until this task actually starts.
+ // waitNanos is only the executor scheduling delay until this
task body starts; it no longer reflects
+ // load-slot contention when using virtual threads. Load
permits are acquired inside the deep-storage
+ // reads now, so that wait is folded into loadTime instead,
and the query-time bundle/column fetches
+ // (separate permit-bounded tasks) are not reflected here at
all. A meaningful load-wait metric would
+ // have to time the permit acquire at the read sites and
aggregate it across those fetches.
final long taskStartNanos = System.nanoTime();
final long waitNanos = taskStartNanos - submitNanos;
final boolean wasMounted = reserved.metadata.isMounted();
@@ -829,7 +831,12 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
}
try {
final LoadSpec loadSpec =
jsonMapper.convertValue(dataSegment.getLoadSpec(), LoadSpec.class);
- return loadSpec.openRangeReader();
+ final SegmentRangeReader rangeReader = loadSpec.openRangeReader();
+ if (rangeReader == null) {
+ return null;
+ }
+ // Bound concurrent deep-storage reads at the actual range-read (see
PermitLimitedSegmentRangeReader).
+ return new PermitLimitedSegmentRangeReader(rangeReader,
virtualStorageLoadingThreadPool);
}
catch (IOException e) {
throw DruidException.forPersona(DruidException.Persona.OPERATOR)
@@ -1235,7 +1242,12 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
throws SegmentLoadingException
{
try {
- return wrapper.openRangeReader();
+ final SegmentRangeReader rangeReader = wrapper.openRangeReader();
+ if (rangeReader == null) {
+ return null;
+ }
+ // Bound concurrent deep-storage reads at the actual range-read (see
PermitLimitedSegmentRangeReader).
+ return new PermitLimitedSegmentRangeReader(rangeReader,
virtualStorageLoadingThreadPool);
}
catch (IOException e) {
throw new SegmentLoadingException(e, "Failed to open range reader for
segment[%s]", dataSegment.getId());
@@ -1659,6 +1671,9 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
final long startTime = System.nanoTime();
return virtualStorageLoadingThreadPool.getExecutorService().submit(
() -> {
+ // waitTime is only the executor scheduling delay; when using
virtual threads for the pool, load-slot
+ // contention is folded into loadTime now, since mount
acquires the load permit around the deep-storage
+ // read.
final long execStartTime = System.nanoTime();
final long waitTime = execStartTime - startTime;
entry.mount(location);
@@ -1952,7 +1967,11 @@ public class SegmentLocalCacheManager implements
SegmentCacheManager
}
}
if (needsLoad) {
- loadInLocationWithStartMarker(dataSegment, storageDir);
+ // Hold a load permit only around the actual deep-storage read,
not the surrounding entryLock or the
+ // factorize/deserialize below. Acquired after entryLock, so a
permit is never held while blocking on it.
+ try (StorageLoadingThreadPool.LoadPermit ignored =
virtualStorageLoadingThreadPool.acquireLoadPermit()) {
+ loadInLocationWithStartMarker(dataSegment, storageDir);
+ }
}
final SegmentizerFactory factory = getSegmentFactory(storageDir);
diff --git
a/server/src/main/java/org/apache/druid/segment/loading/StorageLoadingThreadPool.java
b/server/src/main/java/org/apache/druid/segment/loading/StorageLoadingThreadPool.java
index b76f347cb14..866e5eeeb82 100644
---
a/server/src/main/java/org/apache/druid/segment/loading/StorageLoadingThreadPool.java
+++
b/server/src/main/java/org/apache/druid/segment/loading/StorageLoadingThreadPool.java
@@ -19,9 +19,6 @@
package org.apache.druid.segment.loading;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.util.concurrent.ForwardingListeningExecutorService;
-import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import org.apache.druid.common.asyncresource.AsyncResource;
@@ -30,81 +27,75 @@ import org.apache.druid.error.DruidException;
import org.apache.druid.java.util.common.concurrent.Execs;
import org.apache.druid.java.util.common.lifecycle.LifecycleStop;
import org.apache.druid.java.util.common.logger.Logger;
-import org.apache.druid.segment.PartialBundleAcquirer;
import org.apache.druid.segment.loading.external.VirtualStorageManager;
import javax.annotation.Nullable;
import java.io.Closeable;
-import java.util.Collection;
-import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
-import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
/**
* Holds the thread pool used for background loading by {@link
SegmentLocalCacheManager} and
* {@link VirtualStorageManager}.
+ * <p>
+ * <b>Submissions are not automatically concurrency-bounded.</b> The executor
returned by {@link #getExecutorService()}
+ * runs submitted tasks as fast as it can (in the virtual-thread mode, one
virtual thread per task). Instead, the
+ * number of concurrent <em>deep-storage reads</em> is bounded by a permit
that callers acquire via
+ * {@link #acquireLoadPermit()} around <em>only</em> the actual I/O, never
around lock acquisition, reservation, or
+ * deserialization. Any new load path that reads from deep storage must
acquire a permit around that read, or it will
+ * be unbounded.
*/
public class StorageLoadingThreadPool
{
- private static final Logger log = new Logger(StorageLoadingThreadPool.class);
-
- private final ListeningExecutorService exec;
-
- public StorageLoadingThreadPool(
- @Nullable final ListeningExecutorService exec
- )
- {
- this.exec = exec;
- }
-
public static StorageLoadingThreadPool createFromConfig(final
SegmentLoaderConfig config)
{
- final ListeningExecutorService exec;
+ if (!config.isVirtualStorage()) {
+ return new StorageLoadingThreadPool(null, null);
+ }
- if (config.isVirtualStorage()) {
- if (config.getVirtualStorageLoadThreads() <= 0) {
- throw DruidException.forPersona(DruidException.Persona.OPERATOR)
- .ofCategory(DruidException.Category.INVALID_INPUT)
- .build(
- "virtualStorageLoadThreads must be greater
than 0, got [%d]",
- config.getVirtualStorageLoadThreads()
- );
- }
- if (config.isVirtualStorageUseVirtualThreads()) {
- log.info(
- "Using virtual storage mode with virtual threads - max concurrent
on demand loads: [%d].",
- config.getVirtualStorageLoadThreads()
- );
- exec = new PermitBoundedListeningExecutorService(
- MoreExecutors.listeningDecorator(
- Executors.newThreadPerTaskExecutor(
- Thread.ofVirtual()
- .name("VirtualStorageOnDemandLoadingThread-", 0)
- .factory()
- )
- ),
- new Semaphore(config.getVirtualStorageLoadThreads())
- );
- } else {
- log.info(
- "Using virtual storage mode with fixed platform thread pool - on
demand load threads: [%d].",
- config.getVirtualStorageLoadThreads()
- );
- exec = MoreExecutors.listeningDecorator(
- Executors.newFixedThreadPool(
- config.getVirtualStorageLoadThreads(),
-
Execs.makeThreadFactory("VirtualStorageOnDemandLoadingThread-%s")
- )
- );
- }
- } else {
- exec = null;
+ if (config.getVirtualStorageLoadThreads() <= 0) {
+ throw DruidException.forPersona(DruidException.Persona.OPERATOR)
+ .ofCategory(DruidException.Category.INVALID_INPUT)
+ .build(
+ "virtualStorageLoadThreads must be greater than
0, got [%d]",
+ config.getVirtualStorageLoadThreads()
+ );
}
- return new StorageLoadingThreadPool(exec);
+ final ListeningExecutorService exec;
+ final Semaphore permits;
+ if (config.isVirtualStorageUseVirtualThreads()) {
+ log.info(
+ "Using virtual storage mode with virtual threads - max concurrent on
demand loads: [%d].",
+ config.getVirtualStorageLoadThreads()
+ );
+ // Unbounded thread-per-virtual-thread executor; concurrency is bounded
by the permit count, acquired by callers
+ // via acquireLoadPermit() around the actual deep-storage reads.
+ exec = MoreExecutors.listeningDecorator(
+ Executors.newThreadPerTaskExecutor(
+ Thread.ofVirtual()
+ .name("VirtualStorageOnDemandLoadingThread-", 0)
+ .factory()
+ )
+ );
+ permits = new Semaphore(config.getVirtualStorageLoadThreads());
+ } else {
+ log.info(
+ "Using virtual storage mode with fixed platform thread pool - on
demand load threads: [%d].",
+ config.getVirtualStorageLoadThreads()
+ );
+ // Fixed pool: the thread count is the concurrency bound, so no separate
permit is needed.
+ exec = MoreExecutors.listeningDecorator(
+ Executors.newFixedThreadPool(
+ config.getVirtualStorageLoadThreads(),
+ Execs.makeThreadFactory("VirtualStorageOnDemandLoadingThread-%s")
+ )
+ );
+ permits = null;
+ }
+ return new StorageLoadingThreadPool(exec, permits);
}
/**
@@ -112,7 +103,31 @@ public class StorageLoadingThreadPool
*/
public static StorageLoadingThreadPool none()
{
- return new StorageLoadingThreadPool(null);
+ return new StorageLoadingThreadPool(null, null);
+ }
+
+ private static final Logger log = new Logger(StorageLoadingThreadPool.class);
+
+ /**
+ * A permit handle whose {@code close()} releases nothing. Returned by
{@link #acquireLoadPermit()} when there is no
+ * semaphore (the fixed-thread-pool mode, where the thread count is the
bound, and the "no pool" instance).
+ */
+ private static final LoadPermit NOOP_PERMIT = () -> {};
+
+ @Nullable
+ private final ListeningExecutorService exec;
+ /**
+ * Bounds concurrent on-demand deep-storage reads in the virtual-thread
mode, where the executor is otherwise
+ * unbounded (one virtual thread per task). Null in the fixed-thread-pool
mode (the pool size is the bound) and in
+ * the "no pool" instance.
+ */
+ @Nullable
+ private final Semaphore permits;
+
+ private StorageLoadingThreadPool(@Nullable final ListeningExecutorService
exec, @Nullable final Semaphore permits)
+ {
+ this.exec = exec;
+ this.permits = permits;
}
public boolean isAvailable()
@@ -121,11 +136,8 @@ public class StorageLoadingThreadPool
}
/**
- * Executor for on-demand load work. Concurrency is bounded by the executor
itself: in the virtual-thread path it is
- * a {@link PermitBoundedListeningExecutorService} wrapping an unbounded
thread-per-virtual-thread executor (so the
- * permit count, not the thread count, is the bound); in the fixed-pool path
the pool size is the bound. Every
- * on-demand-load submission, including those routed through {@link
#submitUnmanagedAsyncResource}, is therefore
- * bounded without callers having to acquire a permit themselves.
+ * Executor for on-demand load work. Not concurrency-bounded by itself.
Callers doing deep-storage reads with this
+ * pool must bound themselves via {@link #acquireLoadPermit()} around the
read.
*/
public ListeningExecutorService getExecutorService()
{
@@ -135,6 +147,37 @@ public class StorageLoadingThreadPool
return exec;
}
+ /**
+ * Acquire one on-demand-load permit, returning a {@link LoadPermit} that
releases it (idempotently) on close. Callers
+ * must hold the permit only around the actual deep-storage read, not around
lock acquisition, reservation, or
+ * deserialization.
+ * <p>
+ * In the fixed-thread-pool mode (or the "no pool" instance) there is no
semaphore and this returns a no-op handle
+ * (the thread count, or nothing, is the bound). The acquire is
interruptible: if the thread is interrupted while
+ * waiting for a permit (e.g. a canceled load), the interrupt flag is
restored and a {@link RuntimeException} is
+ * thrown so the load aborts before any I/O begins.
+ */
+ public LoadPermit acquireLoadPermit()
+ {
+ final Semaphore p = permits;
+ if (p == null) {
+ return NOOP_PERMIT;
+ }
+ try {
+ p.acquire();
+ }
+ catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
+ final AtomicBoolean released = new AtomicBoolean(false);
+ return () -> {
+ if (released.compareAndSet(false, true)) {
+ p.release();
+ }
+ };
+ }
+
/**
* Submit a task to the pool and hand back an {@link AsyncResource} that
becomes ready when the task completes,
* exposing the task's (non-null) result. The result is treated as a plain
value with no lifecycle: closing the
@@ -143,7 +186,7 @@ public class StorageLoadingThreadPool
* <p>This is the unmanaged counterpart of {@link
#submitCloseableAsyncResource}; use that when the task's result
* owns a lifecycle.
*
- * @see AsyncResources#fromFutureUnmanaged(ListenableFuture)
+ * @see AsyncResources#fromFutureUnmanaged
*/
public <T> AsyncResource<T> submitUnmanagedAsyncResource(Callable<T> task)
{
@@ -158,7 +201,7 @@ public class StorageLoadingThreadPool
* <p>This is the managed counterpart of {@link
#submitUnmanagedAsyncResource}; use that when the task's result is a
* plain value with no lifecycle.
*
- * @see AsyncResources#fromFutureCloseable(ListenableFuture)
+ * @see AsyncResources#fromFutureCloseable
*/
public <T extends Closeable> AsyncResource<T>
submitCloseableAsyncResource(Callable<T> task)
{
@@ -174,125 +217,16 @@ public class StorageLoadingThreadPool
}
/**
- * A {@link ListeningExecutorService} that caps the number of
concurrently-running submitted tasks at a semaphore's
- * permit count, acquiring a permit (on the worker thread) before each task
body runs and releasing it after. Used to
- * bound concurrent virtual-storage on-demand loads when the backing
executor is an unbounded thread-per-virtual-thread
- * executor: the permit count is the concurrency bound, not the thread
count, and the wait for a permit parks a virtual
- * thread rather than blocking the submitter.
- * <p>
- * The permit wait is <b>interruptible</b>: a task whose future is canceled
with {@code mayInterruptIfRunning} (or a
- * {@code shutdownNow}) while it is parked on the permit is interrupted out
of the wait and aborts before running its
- * body. Canceling a query stops not only its queued column downloads but
also those blocked on the permit,
- * before any deep-storage I/O begins. A task that has already passed the
permit and started its body runs to
- * completion (aborting in-flight reads is handled separately by the task
itself, not here).
- * <p>
- * Only {@code execute}/{@code submit} are bounded; those are the only
submission paths on-demand load work uses
- * (including the {@link PartialBundleAcquirer#submitDownload}
column-download path). {@code invokeAll}/
- * {@code invokeAny} throw {@link UnsupportedOperationException} so the
concurrency bound can never be silently
- * bypassed by a future caller.
- * <p>
- * Callers must not submit a task that itself blocks on another task
submitted to this executor while holding a
- * permit, or all permits could be exhausted by waiters; the on-demand load
tasks here never nest submissions.
+ * A handle to a held on-demand-load permit. {@link #close()} releases the
permit.
+ *
+ * @see #acquireLoadPermit()
*/
- @VisibleForTesting
- static final class PermitBoundedListeningExecutorService extends
ForwardingListeningExecutorService
+ public interface LoadPermit extends Closeable
{
- private final ListeningExecutorService delegate;
- private final Semaphore permits;
-
- PermitBoundedListeningExecutorService(ListeningExecutorService delegate,
Semaphore permits)
- {
- this.delegate = delegate;
- this.permits = permits;
- }
-
+ /**
+ * Releases the permit.
+ */
@Override
- protected ListeningExecutorService delegate()
- {
- return delegate;
- }
-
- @Override
- public void execute(Runnable command)
- {
- delegate.execute(withPermit(command));
- }
-
- @Override
- public <T> ListenableFuture<T> submit(Callable<T> task)
- {
- return delegate.submit(withPermit(task));
- }
-
- @Override
- public ListenableFuture<?> submit(Runnable task)
- {
- return delegate.submit(withPermit(task));
- }
-
- @Override
- public <T> ListenableFuture<T> submit(Runnable task, T result)
- {
- return delegate.submit(withPermit(task), result);
- }
-
- @Override
- public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>>
tasks)
- {
- throw new UnsupportedOperationException("invokeAll is not
permit-bounded; use submit");
- }
-
- @Override
- public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>>
tasks, long timeout, TimeUnit unit)
- {
- throw new UnsupportedOperationException("invokeAll is not
permit-bounded; use submit");
- }
-
- @Override
- public <T> T invokeAny(Collection<? extends Callable<T>> tasks)
- {
- throw new UnsupportedOperationException("invokeAny is not
permit-bounded; use submit");
- }
-
- @Override
- public <T> T invokeAny(Collection<? extends Callable<T>> tasks, long
timeout, TimeUnit unit)
- {
- throw new UnsupportedOperationException("invokeAny is not
permit-bounded; use submit");
- }
-
- private Runnable withPermit(Runnable task)
- {
- return () -> {
- try {
- permits.acquire();
- }
- catch (InterruptedException e) {
- // Interrupted while waiting for a permit (e.g. the task's future
was canceled with mayInterruptIfRunning).
- // Abort before running the body. Restore the flag and surface as a
failure rather than a silent success;
- // if the future was canceled it already reports as such, so this
only matters for a stray interrupt.
- Thread.currentThread().interrupt();
- throw new RuntimeException(e);
- }
- try {
- task.run();
- }
- finally {
- permits.release();
- }
- };
- }
-
- private <T> Callable<T> withPermit(Callable<T> task)
- {
- return () -> {
- permits.acquire();
- try {
- return task.call();
- }
- finally {
- permits.release();
- }
- };
- }
+ void close();
}
}
diff --git
a/server/src/main/java/org/apache/druid/segment/loading/external/StorageLocationVirtualStorageManager.java
b/server/src/main/java/org/apache/druid/segment/loading/external/StorageLocationVirtualStorageManager.java
index c9c22591cff..48243b607e5 100644
---
a/server/src/main/java/org/apache/druid/segment/loading/external/StorageLocationVirtualStorageManager.java
+++
b/server/src/main/java/org/apache/druid/segment/loading/external/StorageLocationVirtualStorageManager.java
@@ -100,6 +100,14 @@ public class StorageLocationVirtualStorageManager
implements VirtualStorageManag
FilePopulator populator
)
{
+ // Hold a load permit only around the actual populate (the deep-storage
read), not the reservation/hold or the
+ // per-identifier population lock below (the permit is acquired inside
populate, which runs on mount).
+ final FilePopulator permittedPopulator = file -> {
+ try (StorageLoadingThreadPool.LoadPermit ignored =
loadingThreadPool.acquireLoadPermit()) {
+ populator.populate(file);
+ }
+ };
+
// Get or create lock for this identifier
final PopulationLock lock = populationLocks.computeIfAbsent(identifier,
ignored -> new PopulationLock());
@@ -125,7 +133,7 @@ public class StorageLocationVirtualStorageManager
implements VirtualStorageManag
// Reserve space and acquire a hold, using a cache entry that will
call the populator on mount.
final StorageLocation.ReservationHold<CacheEntry> hold =
location.addWeakReservationHold(
cacheId,
- () -> new DownloadableCacheEntry(cacheId, sizeBytes,
populator, locationFile)
+ () -> new DownloadableCacheEntry(cacheId, sizeBytes,
permittedPopulator, locationFile)
{
final AtomicBoolean mounted = new AtomicBoolean(false);
diff --git
a/server/src/test/java/org/apache/druid/segment/loading/PermitBoundedListeningExecutorServiceTest.java
b/server/src/test/java/org/apache/druid/segment/loading/PermitBoundedListeningExecutorServiceTest.java
deleted file mode 100644
index bf338e2017f..00000000000
---
a/server/src/test/java/org/apache/druid/segment/loading/PermitBoundedListeningExecutorServiceTest.java
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package org.apache.druid.segment.loading;
-
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.common.util.concurrent.MoreExecutors;
-import org.apache.druid.java.util.common.concurrent.Execs;
-import
org.apache.druid.segment.loading.StorageLoadingThreadPool.PermitBoundedListeningExecutorService;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.Timeout;
-
-import java.util.concurrent.CancellationException;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.Semaphore;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-/**
- * Tests the cancellation behavior of {@link
PermitBoundedListeningExecutorService}: a task parked on the permit (but
- * not yet running its body) must be abortable via {@code cancel(true)}. This
is the "mid-tier" download cancellation
- * that lets the on-demand load path stop queued/permit-blocked column
downloads before they touch deep storage.
- */
-class PermitBoundedListeningExecutorServiceTest
-{
- private ListeningExecutorService backing;
-
- @AfterEach
- void tearDown()
- {
- if (backing != null) {
- backing.shutdownNow();
- }
- }
-
- @Test
- @Timeout(30)
- void testCancelInterruptsTaskWaitingOnPermitBeforeItRuns() throws Exception
- {
- backing = MoreExecutors.listeningDecorator(Execs.multiThreaded(2,
"permit-bounded-test-%d"));
- final Semaphore permits = new Semaphore(1);
- final PermitBoundedListeningExecutorService exec =
- new PermitBoundedListeningExecutorService(backing, permits);
-
- final CountDownLatch holderRunning = new CountDownLatch(1);
- final CountDownLatch releaseHolder = new CountDownLatch(1);
-
- // Task A takes the only permit and parks in its body, so any other task
must wait on the permit to start.
- final ListenableFuture<?> holder = exec.submit(() -> {
- holderRunning.countDown();
- try {
- releaseHolder.await();
- }
- catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- });
- Assertions.assertTrue(holderRunning.await(10, TimeUnit.SECONDS), "holder
task should start and take the permit");
-
- // Task B starts on another thread but blocks acquiring the permit (0
available) before its body can run.
- final AtomicBoolean bodyRan = new AtomicBoolean(false);
- final ListenableFuture<?> blocked = exec.submit(() -> {
- bodyRan.set(true);
- return null;
- });
-
- // Wait until B is genuinely parked on the semaphore, not merely
submitted. Guarded by @Timeout against a hang.
- for (int i = 0; i < 2000 && !permits.hasQueuedThreads(); i++) {
- Thread.sleep(5);
- }
- Assertions.assertTrue(permits.hasQueuedThreads(), "task B should be parked
waiting on the permit");
- Assertions.assertEquals(0, permits.availablePermits(), "the holder still
owns the only permit");
-
- // Cancelling with interruption must abort the permit wait before B's body
runs.
- Assertions.assertTrue(blocked.cancel(true), "cancel(true) should interrupt
the permit-blocked task");
- Assertions.assertTrue(blocked.isCancelled());
- Assertions.assertThrows(CancellationException.class, blocked::get);
- Assertions.assertFalse(bodyRan.get(), "permit-blocked task body must not
run after cancellation");
-
- // An interrupted acquire takes no permit, so cancelling B must not have
consumed or leaked one: the holder still
- // owns the only permit, and releasing the holder returns it.
- Assertions.assertEquals(0, permits.availablePermits());
- releaseHolder.countDown();
- holder.get(10, TimeUnit.SECONDS);
- Assertions.assertEquals(1, permits.availablePermits(), "the holder must
return its permit on completion");
- }
-}
diff --git
a/server/src/test/java/org/apache/druid/segment/loading/PermitLimitedSegmentRangeReaderTest.java
b/server/src/test/java/org/apache/druid/segment/loading/PermitLimitedSegmentRangeReaderTest.java
new file mode 100644
index 00000000000..193d8474b1f
--- /dev/null
+++
b/server/src/test/java/org/apache/druid/segment/loading/PermitLimitedSegmentRangeReaderTest.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.segment.loading;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+
+class PermitLimitedSegmentRangeReaderTest
+{
+ private static StorageLoadingThreadPool oneThreadPool()
+ {
+ return StorageLoadingThreadPool.createFromConfig(
+ new SegmentLoaderConfig()
+ {
+ @Override
+ public int getVirtualStorageLoadThreads()
+ {
+ return 1;
+ }
+ }.setVirtualStorage(true)
+ );
+ }
+
+ @Test
+ @Timeout(30)
+ void testPermitIsReleasedWhenTheReturnedStreamIsClosed() throws IOException
+ {
+ final StorageLoadingThreadPool pool = oneThreadPool();
+ try {
+ final SegmentRangeReader delegate = (filename, offset, length) -> new
ByteArrayInputStream(new byte[0]);
+ final PermitLimitedSegmentRangeReader reader = new
PermitLimitedSegmentRangeReader(delegate, pool);
+
+ // Acquires the single permit and releases it on close. The second read
would hang under @Timeout if the permit
+ // were held for longer than the stream's lifetime.
+ reader.readRange("file", 0, 1).close();
+ reader.readRange("file", 0, 1).close();
+ }
+ finally {
+ pool.stop();
+ }
+ }
+
+ @Test
+ @Timeout(30)
+ void testPermitIsReleasedWhenTheDelegateReadFails() throws IOException
+ {
+ final StorageLoadingThreadPool pool = oneThreadPool();
+ try {
+ final boolean[] fail = {true};
+ final SegmentRangeReader delegate = (filename, offset, length) -> {
+ if (fail[0]) {
+ throw new IOException("boom");
+ }
+ return new ByteArrayInputStream(new byte[0]);
+ };
+ final PermitLimitedSegmentRangeReader reader = new
PermitLimitedSegmentRangeReader(delegate, pool);
+
+ // A failed read must release the permit rather than leak it, so a later
read still acquires it.
+ Assertions.assertThrows(IOException.class, () ->
reader.readRange("file", 0, 1));
+ fail[0] = false;
+ reader.readRange("file", 0, 1).close();
+ }
+ finally {
+ pool.stop();
+ }
+ }
+}
diff --git
a/server/src/test/java/org/apache/druid/segment/loading/StorageLoadingThreadPoolTest.java
b/server/src/test/java/org/apache/druid/segment/loading/StorageLoadingThreadPoolTest.java
index 78c5fde240d..196b1d96437 100644
---
a/server/src/test/java/org/apache/druid/segment/loading/StorageLoadingThreadPoolTest.java
+++
b/server/src/test/java/org/apache/druid/segment/loading/StorageLoadingThreadPoolTest.java
@@ -22,9 +22,40 @@ package org.apache.druid.segment.loading;
import org.apache.druid.error.DruidException;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
class StorageLoadingThreadPoolTest
{
+ private static SegmentLoaderConfig oneVirtualThreadConfig()
+ {
+ return new SegmentLoaderConfig()
+ {
+ @Override
+ public int getVirtualStorageLoadThreads()
+ {
+ return 1;
+ }
+ }.setVirtualStorage(true);
+ }
+
+ private static SegmentLoaderConfig fixedThreadConfig()
+ {
+ return new SegmentLoaderConfig()
+ {
+ @Override
+ public int getVirtualStorageLoadThreads()
+ {
+ return 2;
+ }
+
+ @Override
+ public boolean isVirtualStorageUseVirtualThreads()
+ {
+ return false;
+ }
+ }.setVirtualStorage(true);
+ }
+
@Test
void testCreateFromConfigIsUnavailableWhenNotVirtualStorage()
{
@@ -59,4 +90,58 @@ class StorageLoadingThreadPoolTest
}.setVirtualStorage(true);
Assertions.assertThrows(DruidException.class, () ->
StorageLoadingThreadPool.createFromConfig(config));
}
+
+ @Test
+ @Timeout(30)
+ void testAcquireLoadPermitReleasesOnCloseForReuse()
+ {
+ final StorageLoadingThreadPool pool =
StorageLoadingThreadPool.createFromConfig(oneVirtualThreadConfig());
+ try {
+ final StorageLoadingThreadPool.LoadPermit first =
pool.acquireLoadPermit();
+ first.close();
+ first.close(); // idempotent: a double close must not over-release the
single permit
+ // The permit is available again, so this returns immediately (it would
hang under @Timeout if close() had not
+ // released it, or the extra release above had inflated the count).
+ pool.acquireLoadPermit().close();
+ }
+ finally {
+ pool.stop();
+ }
+ }
+
+ @Test
+ @Timeout(30)
+ void testAcquireLoadPermitIsNoOpWithoutSemaphore()
+ {
+ // Fixed-thread mode has no semaphore (the thread count is the bound), so
acquiring repeatedly without releasing
+ // must never block.
+ final StorageLoadingThreadPool pool =
StorageLoadingThreadPool.createFromConfig(fixedThreadConfig());
+ try {
+ pool.acquireLoadPermit();
+ pool.acquireLoadPermit();
+ pool.acquireLoadPermit().close();
+ }
+ finally {
+ pool.stop();
+ }
+ }
+
+ @Test
+ @Timeout(30)
+ void testAcquireLoadPermitIsInterruptibleAndDoesNotConsumeAPermit()
+ {
+ // A load interrupted while acquiring must abort (throw) and take no
permit - this is the cancel-before-I/O
+ // guarantee that lets a cancelled query stop permit-blocked downloads
before they touch deep storage.
+ final StorageLoadingThreadPool pool =
StorageLoadingThreadPool.createFromConfig(oneVirtualThreadConfig());
+ try {
+ Thread.currentThread().interrupt();
+ Assertions.assertThrows(RuntimeException.class, pool::acquireLoadPermit);
+ Assertions.assertTrue(Thread.interrupted(), "interrupt flag should be
restored (and is cleared here)");
+ // The aborted acquire consumed no permit, so this succeeds (would hang
under @Timeout had it leaked one).
+ pool.acquireLoadPermit().close();
+ }
+ finally {
+ pool.stop();
+ }
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]