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 45128436a9a fix: SettableAsyncResource fire callbacks if closed before 
set (#20074)
45128436a9a is described below

commit 45128436a9a0bc44f8cd60a900cd3d8a58ae02be
Author: Clint Wylie <[email protected]>
AuthorDate: Fri Sep 11 13:03:58 2026 -0700

    fix: SettableAsyncResource fire callbacks if closed before set (#20074)
---
 .../druid/common/asyncresource/AsyncResource.java  |  47 ++++++--
 .../AsyncResourceCanceledException.java            |  35 ++++++
 .../druid/common/asyncresource/AsyncResources.java |  37 ++++--
 .../common/asyncresource/RecoverAsyncResource.java |   6 +
 .../asyncresource/SettableAsyncResource.java       |  43 ++++++-
 .../apache/druid/segment/AsyncCursorHolder.java    |   5 +-
 .../common/asyncresource/AsyncResourcesTest.java   |  11 +-
 .../asyncresource/CollectAsyncResourceTest.java    |  62 ++++++++++
 .../asyncresource/RecoverAsyncResourceTest.java    |  49 +++++++-
 .../asyncresource/SettableAsyncResourceTest.java   | 125 +++++++++++++++++++++
 .../asyncresource/TransformAsyncResourceTest.java  |  26 +++++
 .../druid/segment/AsyncCursorHolderTest.java       |  19 ++++
 .../segment/loading/StorageLoadingThreadPool.java  |   7 +-
 13 files changed, 438 insertions(+), 34 deletions(-)

diff --git 
a/processing/src/main/java/org/apache/druid/common/asyncresource/AsyncResource.java
 
b/processing/src/main/java/org/apache/druid/common/asyncresource/AsyncResource.java
index 6fd8057ecb5..8c4e60688b0 100644
--- 
a/processing/src/main/java/org/apache/druid/common/asyncresource/AsyncResource.java
+++ 
b/processing/src/main/java/org/apache/druid/common/asyncresource/AsyncResource.java
@@ -34,7 +34,9 @@ import java.util.concurrent.TimeoutException;
  * be used for resources that do not need cleanup or are not acquired 
asynchronously, but it is most useful when
  * both are true. The wrapper generally owns the resource lifecycle; see "to 
consume a resource" below for details.
  *
- * <p>To produce a resource, generally you should create and populate {@link 
SettableAsyncResource}.
+ * <p>To produce a resource, generally you should create and populate {@link 
SettableAsyncResource}: complete it
+ * exactly once with {@link SettableAsyncResource#set} or {@link 
SettableAsyncResource#setException}, and leave
+ * {@link #close()} to the consumer, which owns the resource and may close it 
to cancel acquisition early.
  *
  * <p>To consume a resource, use {@link #addReadyCallback(Runnable)}, {@link 
#await()}, or {@link #await(long)}
  * to wait for the resource to become ready. Then use {@link #get()} to 
retrieve the resource. When you are done
@@ -68,22 +70,33 @@ import java.util.concurrent.TimeoutException;
  * cancellation, and in this case, the resource becomes eligible for GC 
without completing the future and therefore
  * without being closed.
  *
- * <p>AsyncResource handles this problem by automatically closing the resource 
in
- * {@link SettableAsyncResource#set(ResourceHolder)} when the {@link 
SettableAsyncResource} has been canceled.
+ * <p>AsyncResource handles this problem by reporting the race to the producer 
instead of dropping the resource: once
+ * this {@link AsyncResource} has been closed, {@link 
SettableAsyncResource#set(ResourceHolder)} becomes a no-op and
+ * returns false, which tells the producer that the object it was handing over 
is orphaned and that closing it is now
+ * the producer's job. Acquisition can also be canceled on the producer side, 
via
+ * {@link SettableAsyncResource#setCanceler(Runnable)}.
  */
 public interface AsyncResource<T> extends Closeable
 {
   /**
-   * Whether resource acquisition has completed (successfully or with 
failure). To wait for this to become true
-   * asynchronously, use {@link #addReadyCallback(Runnable)}. To block until 
readiness, use {@link #await()}
-   * or {@link #await(long)}.
+   * Whether resource acquisition is no longer in progress, i.e. it succeeded, 
failed, was canceled by
+   * {@link #close()}, or was released by {@link 
SettableAsyncResource#release()}. Never goes back to false once true;
+   * use {@link #get()} to find out which of those happened. To wait for this 
to become true asynchronously, use
+   * {@link #addReadyCallback(Runnable)}. To block until readiness, use {@link 
#await()} or {@link #await(long)}.
    */
   boolean isReady();
 
   /**
    * Register a callback to fire when {@link #isReady()} becomes true (whether 
the load succeeded or failed). If the
-   * holder is already ready, the callback fires immediately in the calling 
thread. Callbacks are not fired if
-   * {@link #close()} is called prior to the resource becoming available.
+   * holder is already ready, the callback fires immediately in the calling 
thread. Callbacks also fire when
+   * {@link #close()} cancels acquisition before the resource became 
available, so that a waiting consumer learns it
+   * was aborted; {@link #get()} then throws {@link 
AsyncResourceCanceledException}.
+   *
+   * <p>Firing on close looks redundant, since the same owner both registers 
the callbacks and does the closing, but it
+   * lets that owner cancel itself in one call: when a callback completes 
something downstream, such as a future
+   * holding a query's result, closing the resource runs the callback, which 
handles
+   * {@link AsyncResourceCanceledException} from {@link #get()} and unwinds 
the waiter too, with no separate
+   * cancellation step.
    *
    * <p>Because of the fires-immediately case, the callback can run on the 
REGISTERING thread, not just on whatever
    * thread completes the resource, so a callback must not do blocking or 
expensive work (I/O, deserialization)
@@ -96,8 +109,9 @@ public interface AsyncResource<T> extends Closeable
   /**
    * Retrieve the underlying object. May be called any number of times, and 
the same object will be returned.
    *
-   * <p>Throws {@link DruidException} if the underlying object is not ready or 
if {@link #close()} has been called.
-   * Also throws an exception if the resource acquisition failed.
+   * <p>Throws {@link AsyncResourceCanceledException} if {@link #close()} 
canceled acquisition before it completed, and
+   * {@link DruidException} if the underlying object is not ready or was 
closed after becoming ready. Also throws an
+   * exception if the resource acquisition failed.
    */
   T get();
 
@@ -105,6 +119,9 @@ public interface AsyncResource<T> extends Closeable
    * Block until {@link #isReady()} returns true. Does not close the resource 
if interrupted; callers must still
    * call {@link #close()}.
    *
+   * <p>A {@link #close()} that cancels acquisition wakes the waiter, which 
then throws
+   * {@link AsyncResourceCanceledException}.
+   *
    * <p>Throws {@link DruidException} if {@link #close()} has been called 
prior to this method.
    */
   default T await() throws InterruptedException
@@ -119,6 +136,9 @@ public interface AsyncResource<T> extends Closeable
    * Block until {@link #isReady()} returns true, up to some timeout. Does not 
close the resource if interrupted
    * or if waiting times out; callers must still call {@link #close()}.
    *
+   * <p>A {@link #close()} that cancels acquisition wakes the waiter, which 
then throws
+   * {@link AsyncResourceCanceledException}.
+   *
    * <p>Throws {@link DruidException} if {@link #close()} has been called 
prior to this method.
    */
   default T await(long timeoutMillis) throws InterruptedException, 
TimeoutException
@@ -133,7 +153,12 @@ public interface AsyncResource<T> extends Closeable
 
   /**
    * Closes the resource if it is ready, and has not been released by {@link 
SettableAsyncResource#release()}.
-   * If acquisition is still in progress, it is canceled if possible.
+   * If acquisition is still in progress, it is canceled if possible, and any 
pending
+   * {@link #addReadyCallback(Runnable)} callbacks fire so that waiting 
consumers learn acquisition was aborted
+   * instead of waiting for a completion that will never come. {@link 
#isReady()} then returns true and
+   * {@link #get()} throws {@link AsyncResourceCanceledException}.
+   *
+   * <p>Only the owner of this resource (the consumer) should call this method.
    *
    * <p>Despite {@link Closeable} requiring this method to be idempotent, it 
is not necessarily
    * going to be idempotent. Do not close more than once.
diff --git 
a/processing/src/main/java/org/apache/druid/common/asyncresource/AsyncResourceCanceledException.java
 
b/processing/src/main/java/org/apache/druid/common/asyncresource/AsyncResourceCanceledException.java
new file mode 100644
index 00000000000..05beeab39fb
--- /dev/null
+++ 
b/processing/src/main/java/org/apache/druid/common/asyncresource/AsyncResourceCanceledException.java
@@ -0,0 +1,35 @@
+/*
+ * 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.common.asyncresource;
+
+import java.util.concurrent.CancellationException;
+
+/**
+ * Thrown by {@link AsyncResource#get()} when {@link AsyncResource#close()} 
canceled acquisition before the resource
+ * became available, i.e. the consumer that owned the resource gave up waiting 
for it. Extends
+ * {@link CancellationException} so that consumers who only care that 
something was canceled need no special handling.
+ */
+public class AsyncResourceCanceledException extends CancellationException
+{
+  public AsyncResourceCanceledException(String message)
+  {
+    super(message);
+  }
+}
diff --git 
a/processing/src/main/java/org/apache/druid/common/asyncresource/AsyncResources.java
 
b/processing/src/main/java/org/apache/druid/common/asyncresource/AsyncResources.java
index 91d5a7f837b..a7832937106 100644
--- 
a/processing/src/main/java/org/apache/druid/common/asyncresource/AsyncResources.java
+++ 
b/processing/src/main/java/org/apache/druid/common/asyncresource/AsyncResources.java
@@ -98,8 +98,15 @@ public class AsyncResources
    * Returns an {@link AsyncResource} backed by a {@link ListenableFuture} 
whose result <b>owns a lifecycle</b>: it
    * becomes ready when the future completes, exposing the result via {@link 
AsyncResource#get()}, and the result is
    * managed as a {@link Closeable}. Closing the resource closes the result, 
and a result that completes <i>after</i>
-   * the resource was already closed (a cancel/close-vs-completion race) is 
closed rather than leaked. Closing the
-   * returned resource before the future completes cancels the future ({@code 
cancel(true)}).
+   * the resource was already closed is closed rather than leaked.
+   *
+   * <p>Closing the returned resource deliberately does <b>not</b> cancel the 
future. Cancellation is what makes
+   * futures-of-closeables unsafe in the first place: a task that produces its 
value anyway hands it to a canceled
+   * future, which drops it silently, and nothing is left to close it. Leaving 
the future alone means the result
+   * always arrives through the callback below, which closes it when the 
resource is already gone. The cost is that
+   * work already submitted runs to completion; a producer that wants real 
cancellation should populate a
+   * {@link SettableAsyncResource} itself and give it a {@link 
SettableAsyncResource#setCanceler canceler} that can
+   * abort safely.
    *
    * <p>This is the managed counterpart of {@link #fromFutureUnmanaged}; use 
that for a future whose result is a plain
    * value or a completion signal with no lifecycle.
@@ -107,7 +114,6 @@ public class AsyncResources
   public static <T extends Closeable> AsyncResource<T> 
fromFutureCloseable(final ListenableFuture<T> future)
   {
     final SettableAsyncResource<T> retVal = new SettableAsyncResource<>();
-    retVal.setCanceler(() -> future.cancel(true));
     Futures.addCallback(
         future,
         new FutureCallback<>()
@@ -115,7 +121,7 @@ public class AsyncResources
           @Override
           public void onSuccess(T result)
           {
-            // Lost the race with close()/cancel(): the resource is already 
closed, so set() returns false and we own
+            // Lost the race with close(): the resource is already closed, so 
set() returns false and we own
             // closing the now-orphaned result.
             if (!retVal.set(ResourceHolder.fromCloseable(result))) {
               CloseableUtils.closeAndSuppressExceptions(result, ignored -> {});
@@ -134,9 +140,11 @@ public class AsyncResources
   }
 
   /**
-   * Returns an {@link AsyncResource} that collects a list of underlying 
resources into a single lifecycle.
-   * Calling {@link AsyncResource#close()} on the returned async resource 
causes the underlying async resource
-   * to be closed.
+   * Returns an {@link AsyncResource} whose value is the result of calling 
{@code function} on an underlying resource.
+   *
+   * <p>Once this method returns, the returned {@link AsyncResource} is the 
caller's to close, and closing it also
+   * closes {@code sourceResource}, so the caller must not close {@code 
sourceResource} itself. If this method throws,
+   * nothing has been taken over and the caller still owns {@code 
sourceResource}.
    *
    * <p>The transformation generally happens eagerly in the thread that 
provides the source resource, so it is
    * important that it run quickly.
@@ -155,8 +163,10 @@ public class AsyncResources
 
   /**
    * Returns an {@link AsyncResource} that collects a list of underlying 
resources into a single lifecycle.
-   * Calling {@link AsyncResource#close()} on the returned async resource 
causes the underlying async resources
-   * to also be closed.
+   *
+   * <p>Once this method returns, the returned {@link AsyncResource} is the 
caller's to close, and closing it also
+   * closes every resource in {@code asyncResources}, so the caller must not 
close them itself. If this method throws,
+   * nothing has been taken over and the caller still owns all of them.
    */
   public static <T> AsyncResource<List<T>> collect(final 
List<AsyncResource<T>> asyncResources)
   {
@@ -171,8 +181,13 @@ public class AsyncResources
    * given a chance to substitute a fallback value. Recovery generally happens 
eagerly in the thread that provides
    * the source resource, so it is important that it run quickly.
    *
-   * <p>When recovery happens, the {@code sourceResource} is closed 
immediately. Otherwise, the {@code sourceResoruce}
-   * is closed when the resource returned by this function is closed.
+   * <p>The {@code recoverFn} is not called when the source was canceled by 
{@link AsyncResource#close()}: there is
+   * no consumer left to recover for.
+   *
+   * <p>Once this method returns, the returned {@link AsyncResource} is the 
caller's to close and the caller must not
+   * close {@code sourceResource} itself: it is closed immediately when 
recovery happens, and otherwise when the
+   * returned resource is closed. If this method throws, nothing has been 
taken over and the caller still owns
+   * {@code sourceResource}.
    *
    * <p>The target of {@code function} need not be {@link Closeable}, and even 
if it is {@link Closeable}, it
    * is not closed (only the source is closed). This transform utility is 
meant for transformations that do
diff --git 
a/processing/src/main/java/org/apache/druid/common/asyncresource/RecoverAsyncResource.java
 
b/processing/src/main/java/org/apache/druid/common/asyncresource/RecoverAsyncResource.java
index a83a70fd74f..5e64dc6f5d1 100644
--- 
a/processing/src/main/java/org/apache/druid/common/asyncresource/RecoverAsyncResource.java
+++ 
b/processing/src/main/java/org/apache/druid/common/asyncresource/RecoverAsyncResource.java
@@ -93,6 +93,12 @@ public class RecoverAsyncResource<T> implements 
AsyncResource<T>
     try {
       value = sourceResource.get();
     }
+    catch (AsyncResourceCanceledException e) {
+      // The source was closed before it became available, so this callback is 
firing from its close() and there is no
+      // consumer left to recover for.
+      targetResource.setException(e);
+      return;
+    }
     catch (Throwable e) {
       final T recovered;
       try {
diff --git 
a/processing/src/main/java/org/apache/druid/common/asyncresource/SettableAsyncResource.java
 
b/processing/src/main/java/org/apache/druid/common/asyncresource/SettableAsyncResource.java
index 9894d16a311..050568e2763 100644
--- 
a/processing/src/main/java/org/apache/druid/common/asyncresource/SettableAsyncResource.java
+++ 
b/processing/src/main/java/org/apache/druid/common/asyncresource/SettableAsyncResource.java
@@ -81,6 +81,13 @@ public class SettableAsyncResource<T> implements 
AsyncResource<T>
   @GuardedBy("this")
   private Throwable error = null;
 
+  /**
+   * Whether {@link #close()} happened before the resource became available, 
i.e. acquisition was canceled. Such a
+   * resource is complete: {@link #isReady()} is true and {@link #get()} 
throws {@link AsyncResourceCanceledException}.
+   */
+  @GuardedBy("this")
+  private boolean canceled = false;
+
   @GuardedBy("this")
   private State state = State.NEW;
 
@@ -126,7 +133,8 @@ public class SettableAsyncResource<T> implements 
AsyncResource<T>
    * {@link #addReadyCallback(Runnable)}. Once this method returns true, 
{@link #close()} will no longer call
    * the canceler from {@link #setCanceler(Runnable)}.
    *
-   * <p>If this method returns false, the producer is responsible for closing 
the resource itself.
+   * <p>If this method returns false, the resource was already closed: the 
producer is responsible for closing the
+   * resource itself. The pending callbacks already fired when {@link 
#close()} canceled acquisition.
    *
    * <p>Throws {@link DruidException} if this resource was already completed 
from a prior call to this method or
    * {@link #setException}).
@@ -169,8 +177,11 @@ public class SettableAsyncResource<T> implements 
AsyncResource<T>
    * {@link #addReadyCallback(Runnable)}. Afterwards, {@link #close()} will no 
longer call the canceler from
    * {@link #setCanceler(Runnable)}.
    *
+   * <p>If the resource was already closed, the error is logged at debug and 
otherwise dropped: the consumer already
+   * learned that acquisition was canceled when {@link #close()} fired the 
pending callbacks.
+   *
    * <p>Throws {@link DruidException} if this resource was already completed 
from a prior call to this method or
-   * {@link #set}).
+   * {@link #set}.
    */
   public void setException(Throwable t)
   {
@@ -180,7 +191,9 @@ public class SettableAsyncResource<T> implements 
AsyncResource<T>
   @Override
   public synchronized boolean isReady()
   {
-    return state == State.READY;
+    // anything but NEW means acquisition is over, whether it succeeded, 
failed, was canceled, or was released; get()
+    // reports which. Once true this stays true, so a consumer waiting on it 
can never be sent back to waiting.
+    return state != State.NEW;
   }
 
   @Override
@@ -199,7 +212,12 @@ public class SettableAsyncResource<T> implements 
AsyncResource<T>
         }
       }
       case RELEASED -> throw DruidException.defensive("Resource has been 
released");
-      case CLOSED -> throw DruidException.defensive("Closed");
+      case CLOSED -> {
+        if (canceled) {
+          throw new AsyncResourceCanceledException("Resource acquisition was 
canceled by close()");
+        }
+        throw DruidException.defensive("Closed");
+      }
     };
   }
 
@@ -215,8 +233,8 @@ public class SettableAsyncResource<T> implements 
AsyncResource<T>
    * {@link AsyncResources#collect}, {@link AsyncResources#transform}, etc. 
These combinators will fail to properly
    * encapsulate resource lifecycle if resources have been released.
    *
-   * <p>Throws {@link DruidException} if the holder is not yet ready, has 
already been released, or if
-   * {@link #close()} has been called.
+   * <p>Throws {@link DruidException} if the holder is not yet ready, has 
already been released, or was closed after
+   * becoming ready. Throws {@link AsyncResourceCanceledException} if {@link 
#close()} canceled acquisition first.
    */
   protected synchronized T release()
   {
@@ -254,6 +272,7 @@ public class SettableAsyncResource<T> implements 
AsyncResource<T>
   public void close()
   {
     final Closeable deferredCloseable;
+    final List<Runnable> callbacksToFire;
 
     synchronized (this) {
       deferredCloseable = switch (state) {
@@ -263,6 +282,9 @@ public class SettableAsyncResource<T> implements 
AsyncResource<T>
         default -> throw DruidException.defensive("Already closed");
       };
 
+      canceled = state == State.NEW;
+      callbacksToFire = drainCallbacks();
+
       // Clear result and canceler to allow GC.
       result = null;
       canceler = null;
@@ -273,6 +295,8 @@ public class SettableAsyncResource<T> implements 
AsyncResource<T>
         deferredCloseable,
         e -> LOG.warn(e, "Failed to call cleaner of class[%s]", 
deferredCloseable.getClass())
     );
+
+    fireCallbacks(callbacksToFire);
   }
 
   @GuardedBy("this")
@@ -308,6 +332,13 @@ public class SettableAsyncResource<T> implements 
AsyncResource<T>
       canceler = null;
       callbacksToFire = drainCallbacks();
     }
+
+    if (!didSet && value.isError()) {
+      // Nothing will ever surface this error: the callbacks already fired at 
close() and get() reports the
+      // cancellation, so debug log it rather than let a failure that lost a 
race with close() vanish.
+      LOG.debug(value.error(), "Resource failed after close().");
+    }
+
     fireCallbacks(callbacksToFire);
     return didSet;
   }
diff --git 
a/processing/src/main/java/org/apache/druid/segment/AsyncCursorHolder.java 
b/processing/src/main/java/org/apache/druid/segment/AsyncCursorHolder.java
index d980022d325..fa238954e6f 100644
--- a/processing/src/main/java/org/apache/druid/segment/AsyncCursorHolder.java
+++ b/processing/src/main/java/org/apache/druid/segment/AsyncCursorHolder.java
@@ -35,7 +35,10 @@ import javax.annotation.Nullable;
  * <h3>Consumer protocol</h3>
  * Consumers wait for {@link #isReady()} via {@link #addReadyCallback}, and 
{@link #release()} to transfer ownership of
  * the {@link CursorHolder} (or throw the producer exception). Calling {@link 
#release()} before {@link #isReady()}
- * returns {@code true}, multiple times, or after this holder has been closed 
will throw a {@link DruidException}.
+ * returns {@code true}, multiple times, or after this holder has been closed 
will throw a {@link DruidException}. If
+ * {@link #close()} canceled the load before it completed, {@link #isReady()} 
becomes true and {@link #release()}
+ * throws {@link 
org.apache.druid.common.asyncresource.AsyncResourceCanceledException} instead, 
so a waiting consumer
+ * always finds out.
  * <p>
  * For example (using {@link ReturnOrAwait} to show intended yield-then-resume 
usage pattern):
  * <pre>{@code
diff --git 
a/processing/src/test/java/org/apache/druid/common/asyncresource/AsyncResourcesTest.java
 
b/processing/src/test/java/org/apache/druid/common/asyncresource/AsyncResourcesTest.java
index dc25f7479c3..4e036179308 100644
--- 
a/processing/src/test/java/org/apache/druid/common/asyncresource/AsyncResourcesTest.java
+++ 
b/processing/src/test/java/org/apache/druid/common/asyncresource/AsyncResourcesTest.java
@@ -151,14 +151,21 @@ public class AsyncResourcesTest
   }
 
   @Test
-  public void testFromCloseableFutureCloseBeforeCompleteCancelsFuture()
+  public void 
testFromCloseableFutureCloseBeforeCompleteStillClosesTheLateResult()
   {
     final SettableFuture<CloseableProbe> future = SettableFuture.create();
     final AsyncResource<CloseableProbe> resource = 
AsyncResources.fromFutureCloseable(future);
 
     Assertions.assertFalse(resource.isReady());
     resource.close();
-    Assertions.assertTrue(future.isCancelled(), "closing before completion 
cancels the backing future");
+
+    // The future must be left alone: a canceled future silently discards a 
value set afterwards, so the callback
+    // below would never see the result and nothing would ever close it.
+    Assertions.assertFalse(future.isCancelled(), "closing must not cancel the 
future");
+
+    final CloseableProbe probe = new CloseableProbe();
+    Assertions.assertTrue(future.set(probe), "the producer's completion must 
still be accepted");
+    Assertions.assertEquals(1, probe.closeCount.get(), "a result produced 
after close must be closed, not leaked");
   }
 
   /**
diff --git 
a/processing/src/test/java/org/apache/druid/common/asyncresource/CollectAsyncResourceTest.java
 
b/processing/src/test/java/org/apache/druid/common/asyncresource/CollectAsyncResourceTest.java
index 40ce3e9b4bc..e71e19c8054 100644
--- 
a/processing/src/test/java/org/apache/druid/common/asyncresource/CollectAsyncResourceTest.java
+++ 
b/processing/src/test/java/org/apache/druid/common/asyncresource/CollectAsyncResourceTest.java
@@ -19,6 +19,7 @@
 
 package org.apache.druid.common.asyncresource;
 
+import org.apache.druid.error.DruidException;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
@@ -128,4 +129,65 @@ public class CollectAsyncResourceTest
     collected.close();
     Assertions.assertEquals(1, aCancel.get(), "closing the collect must cancel 
pending sources");
   }
+
+  @Test
+  public void testCloseBeforeReadyWakesConsumerAndReportsCancellation()
+  {
+    final AtomicInteger fired = new AtomicInteger();
+    final SettableAsyncResource<String> a = new SettableAsyncResource<>();
+    final SettableAsyncResource<String> b = new SettableAsyncResource<>();
+
+    final AsyncResource<List<String>> collected = 
AsyncResources.collect(List.of(a, b));
+    collected.addReadyCallback(fired::incrementAndGet);
+
+    // Closing must reach a consumer waiting on the collected resource, not 
just cancel the sources. Each source's
+    // close also drives this class's own onOneSourceReady, which must not 
complete or re-fire anything.
+    collected.close();
+
+    Assertions.assertEquals(1, fired.get(), "closing must wake a waiting 
consumer exactly once");
+    Assertions.assertTrue(collected.isReady());
+    Assertions.assertThrows(AsyncResourceCanceledException.class, 
collected::get);
+  }
+
+  @Test
+  public void testCloseWithAMixOfReadyAndPendingSourcesReportsCancellation()
+  {
+    final AtomicInteger fired = new AtomicInteger();
+    final AtomicInteger aClose = new AtomicInteger();
+    final SettableAsyncResource<String> a = new SettableAsyncResource<>();
+    final SettableAsyncResource<String> b = new SettableAsyncResource<>();
+    a.set("a", aClose::incrementAndGet);
+
+    final AsyncResource<List<String>> collected = 
AsyncResources.collect(List.of(a, b));
+    collected.addReadyCallback(fired::incrementAndGet);
+    Assertions.assertFalse(collected.isReady(), "one source is still pending");
+
+    // The already-ready source counted toward readiness, so closing the 
pending one brings the internal count to the
+    // source count and runs the collect body against sources this close just 
tore down. That must stay harmless.
+    collected.close();
+
+    Assertions.assertEquals(1, fired.get());
+    Assertions.assertEquals(1, aClose.get(), "the ready source's value must 
still be closed");
+    Assertions.assertThrows(AsyncResourceCanceledException.class, 
collected::get);
+  }
+
+  @Test
+  public void testThrowingConstructionLeavesSourcesToTheCaller()
+  {
+    final AtomicInteger aCancel = new AtomicInteger();
+    final SettableAsyncResource<String> a = new SettableAsyncResource<>();
+    a.setCanceler(aCancel::incrementAndGet);
+
+    // A closed source rejects addReadyCallback, so collect throws partway 
through registering. Nothing has been taken
+    // over at that point, so the caller still owns every input: collect must 
not have canceled or closed any of them.
+    final SettableAsyncResource<String> closed = new SettableAsyncResource<>();
+    closed.close();
+
+    Assertions.assertThrows(DruidException.class, () -> 
AsyncResources.collect(List.of(a, closed)));
+    Assertions.assertEquals(0, aCancel.get(), "a failed collect must leave its 
inputs alone");
+
+    // Still the caller's to close, and still usable.
+    a.close();
+    Assertions.assertEquals(1, aCancel.get());
+  }
 }
diff --git 
a/processing/src/test/java/org/apache/druid/common/asyncresource/RecoverAsyncResourceTest.java
 
b/processing/src/test/java/org/apache/druid/common/asyncresource/RecoverAsyncResourceTest.java
index 9a3c695de61..8effaec0ea9 100644
--- 
a/processing/src/test/java/org/apache/druid/common/asyncresource/RecoverAsyncResourceTest.java
+++ 
b/processing/src/test/java/org/apache/druid/common/asyncresource/RecoverAsyncResourceTest.java
@@ -22,6 +22,7 @@ package org.apache.druid.common.asyncresource;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
+import java.util.concurrent.CancellationException;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicReference;
 
@@ -141,12 +142,58 @@ public class RecoverAsyncResourceTest
     // Close the wrapper while the source is still pending.
     recovering.close();
     Assertions.assertEquals(1, source.closeCount.get());
-    Assertions.assertFalse(recovering.isReady());
+    // Canceled acquisition completes the resource, so waiting consumers stop 
waiting.
+    Assertions.assertTrue(recovering.isReady());
 
     // Completing the source after close is a no-op and must not throw.
     Assertions.assertFalse(source.delegate.set("late", null));
   }
 
+  @Test
+  public void testCloseCancellationIsNotRecoveredFrom()
+  {
+    final TrackingAsyncResource<String> source = new TrackingAsyncResource<>();
+    final AtomicInteger recoveryCalls = new AtomicInteger();
+
+    final AsyncResource<String> recovering = AsyncResources.recover(
+        source,
+        e -> {
+          recoveryCalls.incrementAndGet();
+          return "fallback";
+        }
+    );
+
+    // Closing fires the source's ready callback, which lands in this class's 
failure path. Recovering there would run
+    // the fallback for a consumer that has already gone away, so cancellation 
must pass straight through.
+    recovering.close();
+
+    Assertions.assertEquals(0, recoveryCalls.get(), "recovery must not be 
called for a canceled source");
+    Assertions.assertThrows(AsyncResourceCanceledException.class, 
recovering::get);
+  }
+
+  @Test
+  public void testProducerCancellationIsStillRecoveredFrom()
+  {
+    final TrackingAsyncResource<String> source = new TrackingAsyncResource<>();
+    final AtomicInteger recoveryCalls = new AtomicInteger();
+
+    final AsyncResource<String> recovering = AsyncResources.recover(
+        source,
+        e -> {
+          recoveryCalls.incrementAndGet();
+          return "fallback";
+        }
+    );
+
+    // A load canceled underneath a consumer that is still waiting (an 
executor shutdown, say) arrives as a plain
+    // CancellationException rather than AsyncResourceCanceledException. There 
is someone left to serve, so unlike a
+    // close-initiated cancellation this is a failure the fallback should 
cover.
+    source.delegate.setException(new CancellationException("canceled 
underneath a waiting consumer"));
+
+    Assertions.assertEquals(1, recoveryCalls.get(), "recovery must run when 
the consumer is still waiting");
+    Assertions.assertEquals("fallback", recovering.get());
+  }
+
   /**
    * An {@link AsyncResource} that delegates to a {@link 
SettableAsyncResource} and counts {@link #close()} calls,
    * so tests can verify how the source resource's lifecycle is managed.
diff --git 
a/processing/src/test/java/org/apache/druid/common/asyncresource/SettableAsyncResourceTest.java
 
b/processing/src/test/java/org/apache/druid/common/asyncresource/SettableAsyncResourceTest.java
index 43def411a36..6712ee17096 100644
--- 
a/processing/src/test/java/org/apache/druid/common/asyncresource/SettableAsyncResourceTest.java
+++ 
b/processing/src/test/java/org/apache/druid/common/asyncresource/SettableAsyncResourceTest.java
@@ -25,8 +25,12 @@ import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 
 public class SettableAsyncResourceTest
 {
@@ -217,6 +221,127 @@ public class SettableAsyncResourceTest
     Assertions.assertThrows(DruidException.class, resource::close);
   }
 
+  @Test
+  public void testSetExceptionPassesTheCancellationTypeThrough()
+  {
+    final SettableAsyncResource<String> resource = new 
SettableAsyncResource<>();
+    final AsyncResourceCanceledException canceled = new 
AsyncResourceCanceledException("canceled upstream");
+
+    // Wrappers forward a closed source's cancellation to their own target 
verbatim, so this type is accepted like any
+    // other error rather than being reserved for close(). It then means 
"something upstream was closed before it was
+    // ready", which is still what tells RecoverAsyncResource apart from a 
load canceled under a waiting consumer.
+    resource.setException(canceled);
+
+    Assertions.assertTrue(resource.isReady());
+    Assertions.assertSame(canceled, 
Assertions.assertThrows(AsyncResourceCanceledException.class, resource::get));
+  }
+
+  @Test
+  public void testSetExceptionWithTheCancellationTypeIsDroppedAfterClose()
+  {
+    final SettableAsyncResource<String> resource = new 
SettableAsyncResource<>();
+    resource.close();
+
+    // Wrappers propagate a closed source's cancellation to their own 
already-closed target on ordinary cancel paths,
+    // where it is dropped like any other late error.
+    resource.setException(new AsyncResourceCanceledException("propagated from 
a closed source"));
+    Assertions.assertThrows(AsyncResourceCanceledException.class, 
resource::get);
+  }
+
+  @Test
+  public void testCloseBeforeReadyCompletesAsCanceled()
+  {
+    final SettableAsyncResource<String> resource = new 
SettableAsyncResource<>();
+    resource.close();
+
+    // Canceled acquisition is a completion: consumers gate their waiting on 
isReady(), and get() says what happened.
+    Assertions.assertTrue(resource.isReady());
+    Assertions.assertThrows(AsyncResourceCanceledException.class, 
resource::get);
+  }
+
+  @Test
+  public void testCloseAfterReadyIsNotReportedAsCancellation()
+  {
+    final SettableAsyncResource<String> resource = new 
SettableAsyncResource<>();
+    resource.set("value", null);
+    resource.close();
+
+    // Using a resource you closed after it was ready is a coding error, not a 
cancellation.
+    Assertions.assertThrows(DruidException.class, resource::get);
+  }
+
+  @Test
+  public void testReadyCallbackFiredOnceWhenSetLosesRaceWithClose()
+  {
+    // close() before the resource is available fires the pending callbacks, 
so a waiting consumer learns acquisition
+    // was aborted. A producer's late set() that loses the race must not fire 
them a second time: close() drained them.
+    final AtomicInteger fired = new AtomicInteger();
+    final SettableAsyncResource<String> resource = new 
SettableAsyncResource<>();
+    resource.addReadyCallback(fired::incrementAndGet);
+
+    resource.close();
+    Assertions.assertEquals(1, fired.get());
+
+    Assertions.assertFalse(resource.set("value", null));
+    Assertions.assertEquals(1, fired.get());
+  }
+
+  @Test
+  public void testReadyCallbackFiredOnceWhenSetExceptionLosesRaceWithClose()
+  {
+    final AtomicInteger fired = new AtomicInteger();
+    final SettableAsyncResource<String> resource = new 
SettableAsyncResource<>();
+    resource.addReadyCallback(fired::incrementAndGet);
+
+    resource.close();
+    Assertions.assertEquals(1, fired.get());
+
+    // setException on a CLOSED resource is dropped (logged at debug) and must 
not re-fire the callback.
+    resource.setException(new IllegalStateException("late failure"));
+    Assertions.assertEquals(1, fired.get());
+  }
+
+  @Test
+  public void testCloseFiresCallbacksAfterRunningTheCanceler()
+  {
+    // Ordering matters: a woken consumer must observe a fully torn-down 
resource, not one still mid-abort.
+    final List<String> order = new ArrayList<>();
+    final SettableAsyncResource<String> resource = new 
SettableAsyncResource<>();
+    resource.setCanceler(() -> order.add("canceler"));
+    resource.addReadyCallback(() -> order.add("callback"));
+
+    resource.close();
+    Assertions.assertEquals(List.of("canceler", "callback"), order);
+  }
+
+  @Test
+  public void testAwaitIsWokenByCloseAndThrowsCancellation() throws 
InterruptedException
+  {
+    final SettableAsyncResource<String> resource = new 
SettableAsyncResource<>();
+    final AtomicReference<Throwable> outcome = new AtomicReference<>();
+
+    // A bounded await, so a regression that stops waking the waiter fails 
with a TimeoutException instead of hanging.
+    final Thread waiter = new Thread(() -> {
+      try {
+        resource.await(30_000);
+      }
+      catch (Throwable t) {
+        outcome.set(t);
+      }
+    });
+    waiter.start();
+
+    // Wait until the waiter is parked on await()'s latch, so close() cannot 
land before its callback is registered.
+    final long deadlineNanos = System.nanoTime() + 
TimeUnit.SECONDS.toNanos(30);
+    while (waiter.getState() != Thread.State.TIMED_WAITING && 
System.nanoTime() < deadlineNanos) {
+      Thread.onSpinWait();
+    }
+
+    resource.close();
+    waiter.join();
+    Assertions.assertInstanceOf(AsyncResourceCanceledException.class, 
outcome.get());
+  }
+
   @Test
   public void testAwaitReturnsValueWhenReady() throws InterruptedException
   {
diff --git 
a/processing/src/test/java/org/apache/druid/common/asyncresource/TransformAsyncResourceTest.java
 
b/processing/src/test/java/org/apache/druid/common/asyncresource/TransformAsyncResourceTest.java
index 2a066eef7fc..352714bb391 100644
--- 
a/processing/src/test/java/org/apache/druid/common/asyncresource/TransformAsyncResourceTest.java
+++ 
b/processing/src/test/java/org/apache/druid/common/asyncresource/TransformAsyncResourceTest.java
@@ -153,4 +153,30 @@ public class TransformAsyncResourceTest
     transformed.close();
     Assertions.assertEquals(1, sourceCancel.get());
   }
+
+  @Test
+  public void testCloseBeforeReadyWakesConsumerAndReportsCancellation()
+  {
+    final AtomicInteger functionCalls = new AtomicInteger();
+    final AtomicInteger fired = new AtomicInteger();
+    final SettableAsyncResource<Integer> source = new 
SettableAsyncResource<>();
+
+    final AsyncResource<String> transformed = AsyncResources.transform(
+        source,
+        i -> {
+          functionCalls.incrementAndGet();
+          return "v" + i;
+        }
+    );
+    transformed.addReadyCallback(fired::incrementAndGet);
+
+    // Closing must reach a consumer waiting on the transformed resource, not 
just cancel the source. Closing the
+    // source also drives this class's own onSourceReady, which must not fire 
the consumer's callback a second time.
+    transformed.close();
+
+    Assertions.assertEquals(1, fired.get(), "closing must wake a waiting 
consumer exactly once");
+    Assertions.assertTrue(transformed.isReady());
+    Assertions.assertThrows(AsyncResourceCanceledException.class, 
transformed::get);
+    Assertions.assertEquals(0, functionCalls.get(), "the function must not run 
for a canceled source");
+  }
 }
diff --git 
a/processing/src/test/java/org/apache/druid/segment/AsyncCursorHolderTest.java 
b/processing/src/test/java/org/apache/druid/segment/AsyncCursorHolderTest.java
index 409f6b6a16b..c001fcc85ca 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/AsyncCursorHolderTest.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/AsyncCursorHolderTest.java
@@ -27,6 +27,25 @@ import java.util.concurrent.atomic.AtomicInteger;
 
 class AsyncCursorHolderTest
 {
+  @Test
+  void testIsReadyStaysTrueThroughReleaseAndClose()
+  {
+    final CountingCursorHolder holder = new CountingCursorHolder();
+    final AsyncCursorHolder asyncHolder = new AsyncCursorHolder(null);
+    Assertions.assertFalse(asyncHolder.isReady());
+
+    Assertions.assertTrue(asyncHolder.set(holder));
+    Assertions.assertTrue(asyncHolder.isReady());
+
+    // Releasing transfers ownership, and closing afterwards is a no-op, but 
acquisition is over either way: readiness
+    // must not flip back to false, or a consumer that gates its waiting on it 
could be sent back to waiting.
+    asyncHolder.release();
+    Assertions.assertTrue(asyncHolder.isReady());
+
+    asyncHolder.close();
+    Assertions.assertTrue(asyncHolder.isReady());
+  }
+
   @Test
   void testCloseAfterReleaseDoesNotDoubleCloseHolder()
   {
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 866e5eeeb82..f2c802ff21d 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
@@ -195,8 +195,11 @@ public class StorageLoadingThreadPool
 
   /**
    * Submit a task whose result <b>owns a lifecycle</b> and hand back an 
{@link AsyncResource} that manages it: closing
-   * the returned resource closes the result, a result produced after a 
cancel/close race is closed rather than leaked,
-   * and closing it before completion cancels the task.
+   * the returned resource closes the result, and a result produced after that 
close is closed rather than leaked.
+   *
+   * <p>Closing before completion does not cancel the task: it runs to 
completion and its result is closed on arrival.
+   * Interrupting it would let the task hand a lifecycle-owning result to a 
canceled future, which drops it with
+   * nothing left to close it.
    *
    * <p>This is the managed counterpart of {@link 
#submitUnmanagedAsyncResource}; use that when the task's result is a
    * plain value with no lifecycle.


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to