This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven.git
The following commit(s) were added to refs/heads/master by this push:
new c6de104bff Fix #12445: Fix deadlock in AbstractRequestCache when
resolving parent POMs (#12446)
c6de104bff is described below
commit c6de104bff92fc0c4572813bac82935470441dc4
Author: Guillaume Nodet <[email protected]>
AuthorDate: Sat Jul 11 18:48:37 2026 +0200
Fix #12445: Fix deadlock in AbstractRequestCache when resolving parent POMs
(#12446)
* Fix #12445: Fix deadlock in AbstractRequestCache when resolving parent
POMs
Replace the wait/notify pattern in AbstractRequestCache.requests() with
direct value setting via CachingSupplier.complete(). The old pattern
stored a wait-based individualSupplier in CachingSupplier instances
cached across calls. When a re-entrant requests() call (e.g., parent
POM resolution during artifact resolution) retrieved a CachingSupplier
from the cache, it would invoke the outer call's individualSupplier,
which waited on a HashMap that would never be notified — deadlock.
The fix:
- Add CachingSupplier.complete() to set values without invoking the supplier
- Pass a single-item fallback supplier to doCache() so newly created
CachingSuppliers can independently resolve requests in re-entrant
scenarios
- After batch resolution, directly set results via complete()
- Remove the synchronized wait/notify on HashMap entirely
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Fix #12445: Prevent duplicate concurrent resolutions via wait/notifyAll
coordination
Add ThreadLocal re-entrancy detection and
CachingSupplier.setBatchResolving()
so that concurrent threads wait for an in-progress batch result (via
Object.wait/notifyAll) instead of resolving the same request independently,
while same-thread re-entrant calls still use the fallback supplier to
avoid deadlock. Also fix CachingTestRequestCache to use ConcurrentHashMap,
clean up duplicate javadoc, and add concurrent resolution test.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Fix #12445: Use IdentityHashMap for reqToIndex to handle unstable hashCode
ResolverRequest.hashCode() can change during batch resolution because
RequestTrace includes mutable ModelBuilderRequest data (identified in
PR #12166). Since reqToIndex always uses the same object references
for put and get, IdentityHashMap is safe and avoids missed lookups
that would cause redundant individual resolutions.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Fix #12445: Add test for batch resolution with unstable hashCode
Verify that batch results are correctly delivered via complete() even
when request hashCode() changes during resolution (simulating
ResolverRequest with mutable RequestTrace/ModelBuilderRequest data).
The IdentityHashMap-based reqToIndex ensures lookups succeed by
reference identity regardless of hashCode mutations.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Fix #12445: Address Copilot review — consistent error handling and safe
unblocking
- catch(Throwable) now falls through to the collection loop and produces
a proper BatchRequestException with per-request results, consistent
with the MavenExecutionException path.
- setBatchResolving(false) now calls notifyAll() to unblock any threads
still waiting in apply() when complete() was never called (e.g., batch
supplier returned fewer results than expected).
- Add test for partial batch result edge case.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
.../maven/impl/cache/AbstractRequestCache.java | 122 +++++--
.../apache/maven/impl/cache/CachingSupplier.java | 67 +++-
.../maven/impl/cache/AbstractRequestCacheTest.java | 391 +++++++++++++++++++++
3 files changed, 546 insertions(+), 34 deletions(-)
diff --git
a/impl/maven-impl/src/main/java/org/apache/maven/impl/cache/AbstractRequestCache.java
b/impl/maven-impl/src/main/java/org/apache/maven/impl/cache/AbstractRequestCache.java
index 0b7fac393b..d1706a5250 100644
---
a/impl/maven-impl/src/main/java/org/apache/maven/impl/cache/AbstractRequestCache.java
+++
b/impl/maven-impl/src/main/java/org/apache/maven/impl/cache/AbstractRequestCache.java
@@ -19,9 +19,11 @@
package org.apache.maven.impl.cache;
import java.util.ArrayList;
-import java.util.HashMap;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.function.Function;
import org.apache.maven.api.cache.BatchRequestException;
@@ -43,6 +45,27 @@
*/
public abstract class AbstractRequestCache implements RequestCache {
+ /**
+ * Tracks which {@link CachingSupplier} instances are currently being
batch-resolved
+ * on the current thread. Used by {@link CachingSupplier#apply} to detect
re-entrant
+ * calls and avoid deadlock: if the current thread is already resolving a
CachingSupplier,
+ * {@code apply()} will invoke the fallback supplier instead of waiting for
+ * {@link CachingSupplier#complete} (which would never arrive on the same
thread).
+ */
+ private static final ThreadLocal<Set<CachingSupplier<?, ?>>>
RESOLVING_ON_THREAD =
+ ThreadLocal.withInitial(HashSet::new);
+
+ /**
+ * Checks whether the given CachingSupplier is currently being
batch-resolved
+ * on the calling thread.
+ *
+ * @param cs the CachingSupplier to check
+ * @return {@code true} if a batch resolution for {@code cs} is in
progress on this thread
+ */
+ static boolean isResolvingOnCurrentThread(CachingSupplier<?, ?> cs) {
+ return RESOLVING_ON_THREAD.get().contains(cs);
+ }
+
/**
* Executes and optionally caches a single request.
* <p>
@@ -70,8 +93,20 @@ public <REQ extends Request<?>, REP extends Result<REQ>> REP
request(REQ req, Fu
* only the non-cached requests using the provided supplier function.
* </p>
* <p>
- * If any request in the batch fails, a {@link BatchRequestException} is
thrown, containing
- * details of all failed requests.
+ * This implementation uses {@link CachingSupplier#complete} with {@code
wait/notifyAll}
+ * to coordinate batch results, and a {@link ThreadLocal} re-entrancy
guard to prevent
+ * deadlocks when batch resolution triggers nested calls (e.g., parent POM
resolution
+ * during artifact resolution).
+ * </p>
+ * <p>
+ * <b>Concurrent calls</b> for the same request on different threads: the
first thread
+ * performs the resolution; the second thread's {@link
CachingSupplier#apply} blocks
+ * (via {@code Object.wait()}) until {@code complete()} is called,
avoiding duplicate work.
+ * </p>
+ * <p>
+ * <b>Re-entrant calls</b> on the same thread: detected via {@link
#RESOLVING_ON_THREAD}.
+ * {@link CachingSupplier#apply} skips the wait and invokes the fallback
supplier directly
+ * so the inner call can complete without waiting for the outer call's
batch result.
* </p>
*
* @param <REQ> The request type
@@ -85,57 +120,84 @@ public <REQ extends Request<?>, REP extends Result<REQ>>
REP request(REQ req, Fu
@SuppressWarnings("unchecked")
public <REQ extends Request<?>, REP extends Result<REQ>> List<REP>
requests(
List<REQ> reqs, Function<List<REQ>, List<REP>> supplier) {
- final Map<REQ, Object> nonCachedResults = new HashMap<>();
- List<RequestResult<REQ, REP>> allResults = new
ArrayList<>(reqs.size());
-
- Function<REQ, REP> individualSupplier = req -> {
- synchronized (nonCachedResults) {
- while (!nonCachedResults.containsKey(req)) {
- try {
- nonCachedResults.wait();
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- throw new RuntimeException(e);
- }
- }
- Object val = nonCachedResults.get(req);
- if (val instanceof CachingSupplier.AltRes altRes) {
- uncheckedThrow(altRes.throwable);
- }
- return (REP) val;
- }
- };
+ // Create a fallback supplier that can resolve individual requests
independently.
+ // This is stored in newly created CachingSupplier instances and used
only when
+ // a re-entrant call on the same thread needs to resolve a request
whose batch
+ // resolution is still in progress higher up the call stack.
+ Function<REQ, REP> singleSupplier = req ->
supplier.apply(List.of(req)).get(0);
List<CachingSupplier<REQ, REP>> suppliers = new
ArrayList<>(reqs.size());
List<REQ> nonCached = new ArrayList<>();
for (REQ req : reqs) {
- CachingSupplier<REQ, REP> cs = doCache(req, individualSupplier);
+ CachingSupplier<REQ, REP> cs = doCache(req, singleSupplier);
suppliers.add(cs);
if (cs.getValue() == null) {
nonCached.add(req);
}
}
+ // Resolve non-cached requests in batch and directly set results on
CachingSuppliers
if (!nonCached.isEmpty()) {
- synchronized (nonCachedResults) {
+ // Use IdentityHashMap: request objects may have unstable
hashCode() due to
+ // mutable RequestTrace/ModelBuilderRequest data that changes
during batch resolution.
+ // Since we always use the same object references for put and get,
identity is safe.
+ Map<REQ, Integer> reqToIndex = new IdentityHashMap<>();
+ List<CachingSupplier<REQ, REP>> nonCachedSuppliers = new
ArrayList<>(nonCached.size());
+ for (int i = 0; i < reqs.size(); i++) {
+ if (suppliers.get(i).getValue() == null) {
+ reqToIndex.put(reqs.get(i), i);
+ nonCachedSuppliers.add(suppliers.get(i));
+ }
+ }
+
+ // Mark these CachingSuppliers as being batch-resolved, and
register them
+ // on this thread so that re-entrant apply() calls skip the wait.
+ Set<CachingSupplier<?, ?>> resolving = RESOLVING_ON_THREAD.get();
+ for (CachingSupplier<REQ, REP> cs : nonCachedSuppliers) {
+ cs.setBatchResolving(true);
+ resolving.add(cs);
+ }
+ try {
try {
List<REP> reps = supplier.apply(nonCached);
for (int i = 0; i < reps.size(); i++) {
- nonCachedResults.put(nonCached.get(i), reps.get(i));
+ Integer idx = reqToIndex.get(nonCached.get(i));
+ if (idx != null) {
+ suppliers.get(idx).complete(reps.get(i));
+ }
}
} catch (MavenExecutionException e) {
// If batch request fails, mark all non-cached requests as
failed
+ CachingSupplier.AltRes failure = new
CachingSupplier.AltRes(e.getCause());
+ for (REQ req : nonCached) {
+ Integer idx = reqToIndex.get(req);
+ if (idx != null) {
+ suppliers.get(idx).complete(failure);
+ }
+ }
+ } catch (Throwable e) {
+ // Ensure waiting concurrent threads are unblocked on
unexpected errors.
+ // We mark all non-cached requests as failed and fall
through to the
+ // collection loop, which produces a consistent
BatchRequestException
+ // with per-request RequestResult details — same as
MavenExecutionException.
+ CachingSupplier.AltRes failure = new
CachingSupplier.AltRes(e);
for (REQ req : nonCached) {
- nonCachedResults.put(
- req, new
CachingSupplier.AltRes(e.getCause())); // Mark as processed but failed
+ Integer idx = reqToIndex.get(req);
+ if (idx != null) {
+ suppliers.get(idx).complete(failure);
+ }
}
- } finally {
- nonCachedResults.notifyAll();
+ }
+ } finally {
+ for (CachingSupplier<REQ, REP> cs : nonCachedSuppliers) {
+ cs.setBatchResolving(false);
+ resolving.remove(cs);
}
}
}
// Collect results in original order
+ List<RequestResult<REQ, REP>> allResults = new
ArrayList<>(reqs.size());
boolean hasFailures = false;
for (int i = 0; i < reqs.size(); i++) {
REQ req = reqs.get(i);
diff --git
a/impl/maven-impl/src/main/java/org/apache/maven/impl/cache/CachingSupplier.java
b/impl/maven-impl/src/main/java/org/apache/maven/impl/cache/CachingSupplier.java
index d1960b1944..ea39b37e45 100644
---
a/impl/maven-impl/src/main/java/org/apache/maven/impl/cache/CachingSupplier.java
+++
b/impl/maven-impl/src/main/java/org/apache/maven/impl/cache/CachingSupplier.java
@@ -39,6 +39,25 @@ public Object getValue() {
return value;
}
+ /**
+ * Directly sets the cached value without invoking the supplier, then
notifies
+ * any threads blocked in {@link #apply(Object)} waiting for the result.
+ * <p>
+ * This is used by {@link AbstractRequestCache#requests} to set
batch-resolved results
+ * on CachingSupplier instances. Concurrent callers blocked in {@code
apply()} will
+ * be woken up and see this value instead of invoking the supplier
redundantly.
+ *
+ * @param result the result to cache (may be a normal result or an {@link
AltRes} for errors)
+ */
+ public void complete(Object result) {
+ synchronized (this) {
+ if (value == null) {
+ value = result;
+ }
+ this.notifyAll();
+ }
+ }
+
@Override
@SuppressWarnings({"unchecked", "checkstyle:InnerAssignment"})
public REP apply(REQ req) {
@@ -46,10 +65,27 @@ public REP apply(REQ req) {
if ((v = value) == null) {
synchronized (this) {
if ((v = value) == null) {
- try {
- v = value = supplier.apply(req);
- } catch (Exception e) {
- v = value = new AltRes(e);
+ // If a batch resolution is in progress on another thread,
wait for
+ // complete() rather than invoking the supplier
redundantly.
+ // Re-entrant calls on the SAME thread must NOT wait (that
would deadlock),
+ // so AbstractRequestCache marks CachingSuppliers it is
currently resolving
+ // in a ThreadLocal; those are excluded from waiting.
+ if (batchResolving &&
!AbstractRequestCache.isResolvingOnCurrentThread(this)) {
+ while ((v = value) == null && batchResolving) {
+ try {
+ this.wait();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ }
+ if ((v = value) == null) {
+ try {
+ v = value = supplier.apply(req);
+ } catch (Exception e) {
+ v = value = new AltRes(e);
+ }
}
}
}
@@ -60,6 +96,29 @@ public REP apply(REQ req) {
return (REP) v;
}
+ /**
+ * Marks this supplier as having a batch resolution in progress.
+ * Concurrent threads calling {@link #apply} will wait for {@link
#complete}
+ * instead of invoking the supplier independently.
+ * <p>
+ * When clearing the flag ({@code resolving = false}), any threads still
blocked
+ * in {@link #apply(Object)} are notified so they can proceed to the
fallback
+ * supplier. This handles edge cases where {@link #complete} was never
called
+ * (e.g., batch supplier returned fewer results than expected).
+ *
+ * @param resolving {@code true} when batch resolution starts, {@code
false} when it ends
+ */
+ void setBatchResolving(boolean resolving) {
+ this.batchResolving = resolving;
+ if (!resolving) {
+ synchronized (this) {
+ this.notifyAll();
+ }
+ }
+ }
+
+ private volatile boolean batchResolving;
+
/**
* Special holder class for exceptions that occur during supplier
execution.
* Allows caching and re-throwing of exceptions on subsequent calls.
diff --git
a/impl/maven-impl/src/test/java/org/apache/maven/impl/cache/AbstractRequestCacheTest.java
b/impl/maven-impl/src/test/java/org/apache/maven/impl/cache/AbstractRequestCacheTest.java
index 5de697c15e..e52a048ff2 100644
---
a/impl/maven-impl/src/test/java/org/apache/maven/impl/cache/AbstractRequestCacheTest.java
+++
b/impl/maven-impl/src/test/java/org/apache/maven/impl/cache/AbstractRequestCacheTest.java
@@ -20,6 +20,14 @@
import java.util.Arrays;
import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
import java.util.function.Function;
import org.apache.maven.api.ProtoSession;
@@ -159,6 +167,285 @@ void testSuccessfulBatchRequestDoesNotThrowException() {
assertEquals(request2, results.get(1).getRequest());
}
+ /**
+ * Tests that re-entrant calls to {@code requests()} do not deadlock.
+ * <p>
+ * This reproduces the scenario from issue #12445: an outer {@code
requests()} call
+ * creates CachingSupplier instances that are stored in the cache. During
batch resolution
+ * (inside the outer call's batch supplier), a nested {@code requests()}
call is triggered
+ * (e.g., parent POM resolution during artifact resolution). If the inner
call hits the
+ * same cache entry (same request key), it gets back the CachingSupplier
from the outer call.
+ * <p>
+ * Before the fix, the CachingSupplier wrapped a wait-based supplier that
referenced the
+ * outer call's {@code nonCachedResults} HashMap. The inner call would
wait on that HashMap
+ * forever, since the outer call couldn't populate it until the inner call
completed.
+ */
+ @Test
+ void testReentrantRequestsDoesNotDeadlock() throws Exception {
+ // Use a caching implementation that stores CachingSuppliers in a
shared map
+ CachingTestRequestCache cachingCache = new CachingTestRequestCache();
+
+ // "parentPom" is the request that will be resolved by both the outer
and inner calls
+ TestRequest artifact = createTestRequest("artifact");
+ TestRequest parentPom = createTestRequest("parentPom");
+
+ // The outer batch supplier resolves requests, but during resolution
of "artifact",
+ // it triggers a nested requests() call for "parentPom"
+ Function<List<TestRequest>, List<TestResult>> outerBatchSupplier =
reqs -> {
+ List<TestResult> results = new java.util.ArrayList<>();
+ for (TestRequest req : reqs) {
+ if (req.equals(artifact)) {
+ // Simulate parent POM resolution: re-entrant call for
"parentPom"
+ List<TestResult> innerResults = cachingCache.requests(
+ List.of(parentPom),
+ innerReqs ->
innerReqs.stream().map(TestResult::new).toList());
+ // After inner call completes, outer resolution succeeds
+ assertEquals(1, innerResults.size());
+ }
+ results.add(new TestResult(req));
+ }
+ return results;
+ };
+
+ // Execute with a timeout to detect deadlock
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ try {
+ Future<List<TestResult>> future =
+ executor.submit(() ->
cachingCache.requests(List.of(artifact, parentPom), outerBatchSupplier));
+
+ // If this deadlocks, the future will time out
+ List<TestResult> results = future.get(5, TimeUnit.SECONDS);
+
+ assertEquals(2, results.size());
+ assertEquals(artifact, results.get(0).getRequest());
+ assertEquals(parentPom, results.get(1).getRequest());
+ } catch (TimeoutException e) {
+ throw new AssertionError(
+ "Deadlock detected: re-entrant requests() call did not
complete within 5 seconds", e);
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ /**
+ * Tests that a concurrent singular {@code request()} call waits for an
+ * in-progress batch resolution instead of invoking the supplier
independently.
+ * <p>
+ * Thread A starts a batch resolution via {@code requests()} (which marks
the
+ * CachingSupplier as {@code batchResolving} and registers it on the
current thread).
+ * While Thread A is still inside its batch supplier, Thread B calls
{@code request()}
+ * for the same key. Thread B's {@code cs.apply()} should see {@code
batchResolving == true},
+ * wait for {@code complete()}, and return the batch result without
running its own supplier.
+ */
+ @Test
+ void testConcurrentRequestDoesNotDuplicateResolution() throws Exception {
+ CachingTestRequestCache cachingCache = new CachingTestRequestCache();
+
+ TestRequest sharedReq = createTestRequest("shared");
+
+ java.util.concurrent.atomic.AtomicInteger resolutionCount = new
java.util.concurrent.atomic.AtomicInteger(0);
+ CountDownLatch batchStarted = new CountDownLatch(1);
+ CountDownLatch proceedWithBatch = new CountDownLatch(1);
+
+ // Thread A's batch supplier: signals when it starts, then waits
before completing
+ Function<List<TestRequest>, List<TestResult>> slowBatchSupplier = reqs
-> {
+ resolutionCount.incrementAndGet();
+ batchStarted.countDown();
+ try {
+ proceedWithBatch.await(5, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ return reqs.stream().map(TestResult::new).toList();
+ };
+
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ try {
+ // Thread A: starts batch resolution via requests(), pauses inside
the supplier
+ Future<List<TestResult>> futureA =
+ executor.submit(() ->
cachingCache.requests(List.of(sharedReq), slowBatchSupplier));
+
+ // Wait for Thread A's batch supplier to start
+ assertTrue(batchStarted.await(5, TimeUnit.SECONDS), "Thread A's
batch should have started");
+
+ // Thread B: calls request() (singular) for the same key.
+ // It gets the same CachingSupplier from the cache, sees
batchResolving == true,
+ // and should wait for Thread A's complete() instead of invoking
its own supplier.
+ Future<TestResult> futureB = executor.submit(() ->
cachingCache.request(sharedReq, req -> {
+ resolutionCount.incrementAndGet();
+ return new TestResult(req);
+ }));
+
+ // Give Thread B time to enter apply() and start waiting
+ Thread.sleep(200);
+
+ // Let Thread A's batch complete — this calls complete() which
wakes Thread B
+ proceedWithBatch.countDown();
+
+ // Both should complete
+ List<TestResult> resultsA = futureA.get(5, TimeUnit.SECONDS);
+ TestResult resultB = futureB.get(5, TimeUnit.SECONDS);
+
+ assertEquals(1, resultsA.size());
+ assertNotNull(resultB);
+
+ // The shared request should have been resolved only once (by
Thread A's batch).
+ // Thread B should have waited for Thread A's complete() call, not
invoked its
+ // own supplier.
+ assertEquals(1, resolutionCount.get(), "Request should be resolved
only once, not duplicated");
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ /**
+ * Tests that batch resolution is resilient to requests whose {@code
hashCode()} changes
+ * during resolution (e.g., because {@code RequestTrace} includes mutable
+ * {@code ModelBuilderRequest} data).
+ * <p>
+ * The {@code reqToIndex} map inside {@code requests()} uses {@code
IdentityHashMap}
+ * specifically to handle this: after the batch supplier mutates request
data,
+ * lookups still succeed because they compare by reference identity, not by
+ * {@code equals()/hashCode()}.
+ */
+ @Test
+ void testBatchResolutionWithUnstableHashCode() {
+ // Use identity-based cache to match real DefaultRequestCache behavior
+ IdentityCachingTestRequestCache cachingCache = new
IdentityCachingTestRequestCache();
+
+ // Create requests with mutable trace data that affects hashCode()
+ MutableHashCodeRequest req1 = new MutableHashCodeRequest("req1",
"traceA");
+ MutableHashCodeRequest req2 = new MutableHashCodeRequest("req2",
"traceB");
+
+ int originalHash1 = req1.hashCode();
+ int originalHash2 = req2.hashCode();
+
+ java.util.concurrent.atomic.AtomicInteger supplierCallCount = new
java.util.concurrent.atomic.AtomicInteger(0);
+
+ Function<List<MutableHashCodeRequest>, List<MutableHashCodeResult>>
batchSupplier = reqs -> {
+ supplierCallCount.incrementAndGet();
+ // Mutate trace data during resolution — this changes hashCode()
+ for (MutableHashCodeRequest r : reqs) {
+ r.setTraceData(r.getTraceData() + "-mutated");
+ }
+ return reqs.stream().map(MutableHashCodeResult::new).toList();
+ };
+
+ // Resolve the batch — hashCode changes inside the supplier
+ List<MutableHashCodeResult> results =
cachingCache.requests(List.of(req1, req2), batchSupplier);
+
+ // Verify results were delivered despite hashCode mutation
+ assertEquals(2, results.size());
+ assertEquals(req1, results.get(0).getRequest());
+ assertEquals(req2, results.get(1).getRequest());
+ assertEquals(1, supplierCallCount.get());
+
+ // Verify hashCode actually changed
+ assertTrue(
+ req1.hashCode() != originalHash1 || req2.hashCode() !=
originalHash2,
+ "hashCode should have changed after mutation");
+ }
+
+ /**
+ * Tests that a concurrent {@code request()} call does not hang when the
batch
+ * supplier returns fewer results than expected (partial batch).
+ * <p>
+ * In this scenario, {@link CachingSupplier#complete} is never called for
one
+ * of the CachingSuppliers. The waiting thread in {@code apply()} must
still
+ * be unblocked when {@code setBatchResolving(false)} is called in the
+ * {@code finally} block, which calls {@code notifyAll()} to wake any
waiters.
+ * The unblocked thread then falls through to the fallback supplier.
+ */
+ @Test
+ void testConcurrentRequestUnblockedOnPartialBatchResult() throws Exception
{
+ CachingTestRequestCache cachingCache = new CachingTestRequestCache();
+
+ TestRequest req1 = createTestRequest("req1");
+ TestRequest req2 = createTestRequest("req2");
+
+ CountDownLatch batchStarted = new CountDownLatch(1);
+ CountDownLatch proceedWithBatch = new CountDownLatch(1);
+
+ // Batch supplier that only resolves req1, "forgetting" req2
+ Function<List<TestRequest>, List<TestResult>> partialBatchSupplier =
reqs -> {
+ batchStarted.countDown();
+ try {
+ proceedWithBatch.await(5, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ // Return only the first result — req2's CachingSupplier never
gets complete()
+ return List.of(new TestResult(reqs.get(0)));
+ };
+
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ try {
+ // Thread A: starts batch resolution for [req1, req2]
+ Future<List<TestResult>> futureA =
+ executor.submit(() -> cachingCache.requests(List.of(req1,
req2), partialBatchSupplier));
+
+ // Wait for Thread A's batch supplier to start
+ assertTrue(batchStarted.await(5, TimeUnit.SECONDS), "Batch should
have started");
+
+ // Thread B: calls request() for req2 — gets the same
CachingSupplier,
+ // sees batchResolving == true, and waits in apply()
+ Future<TestResult> futureB = executor.submit(() ->
cachingCache.request(req2, req -> {
+ // Fallback supplier — should be invoked after
setBatchResolving(false)
+ return new TestResult(req);
+ }));
+
+ // Give Thread B time to enter apply() and start waiting
+ Thread.sleep(200);
+
+ // Let Thread A's batch complete — only resolves req1
+ proceedWithBatch.countDown();
+
+ // Thread A should complete (req2 gets resolved via fallback in
collection loop)
+ List<TestResult> resultsA = futureA.get(5, TimeUnit.SECONDS);
+ assertNotNull(resultsA);
+
+ // Thread B should also complete — setBatchResolving(false) +
notifyAll()
+ // unblocks it, and it falls through to its fallback supplier
+ TestResult resultB = futureB.get(5, TimeUnit.SECONDS);
+ assertNotNull(resultB);
+ assertEquals(req2, resultB.getRequest());
+ } catch (TimeoutException e) {
+ throw new AssertionError("Thread hung: setBatchResolving(false)
did not unblock waiting thread", e);
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ /**
+ * Tests that batch results are properly cached in CachingSupplier
instances
+ * so subsequent calls return the cached values.
+ */
+ @Test
+ void testBatchResultsAreCached() {
+ CachingTestRequestCache cachingCache = new CachingTestRequestCache();
+
+ TestRequest req1 = createTestRequest("req1");
+ TestRequest req2 = createTestRequest("req2");
+
+ java.util.concurrent.atomic.AtomicInteger supplierCallCount = new
java.util.concurrent.atomic.AtomicInteger(0);
+
+ Function<List<TestRequest>, List<TestResult>> batchSupplier = reqs -> {
+ supplierCallCount.incrementAndGet();
+ return reqs.stream().map(TestResult::new).toList();
+ };
+
+ // First call should invoke the batch supplier
+ List<TestResult> results1 = cachingCache.requests(List.of(req1, req2),
batchSupplier);
+ assertEquals(2, results1.size());
+ assertEquals(1, supplierCallCount.get());
+
+ // Second call with same requests should use cached values
+ List<TestResult> results2 = cachingCache.requests(List.of(req1, req2),
batchSupplier);
+ assertEquals(2, results2.size());
+ // Supplier should not have been called again
+ assertEquals(1, supplierCallCount.get());
+ }
+
// Helper methods and test classes
private TestRequest createTestRequest(String id) {
@@ -228,6 +515,110 @@ public TestRequest getRequest() {
}
}
+ /**
+ * A cache implementation that stores CachingSupplier instances in a
shared map,
+ * simulating the real DefaultRequestCache behavior where the same
CachingSupplier
+ * can be returned for the same request key across different requests()
calls.
+ */
+ static class CachingTestRequestCache extends AbstractRequestCache {
+ private final Map<TestRequest, CachingSupplier<?, ?>> cache = new
ConcurrentHashMap<>();
+
+ @Override
+ @SuppressWarnings("unchecked")
+ protected <REQ extends Request<?>, REP extends Result<REQ>>
CachingSupplier<REQ, REP> doCache(
+ REQ req, Function<REQ, REP> supplier) {
+ return (CachingSupplier<REQ, REP>)
+ cache.computeIfAbsent((TestRequest) req, r -> new
CachingSupplier<>(supplier));
+ }
+ }
+
+ /**
+ * A request implementation whose hashCode() depends on mutable trace data,
+ * simulating ResolverRequest with mutable
RequestTrace/ModelBuilderRequest data.
+ */
+ static class MutableHashCodeRequest implements Request<ProtoSession> {
+ private final String id;
+ private String traceData;
+
+ MutableHashCodeRequest(String id, String traceData) {
+ this.id = id;
+ this.traceData = traceData;
+ }
+
+ String getTraceData() {
+ return traceData;
+ }
+
+ void setTraceData(String traceData) {
+ this.traceData = traceData;
+ }
+
+ @Override
+ @Nonnull
+ public ProtoSession getSession() {
+ return mock(ProtoSession.class);
+ }
+
+ @Override
+ public RequestTrace getTrace() {
+ return null;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null || getClass() != obj.getClass()) {
+ return false;
+ }
+ MutableHashCodeRequest that = (MutableHashCodeRequest) obj;
+ return java.util.Objects.equals(id, that.id) &&
java.util.Objects.equals(traceData, that.traceData);
+ }
+
+ @Override
+ public int hashCode() {
+ return java.util.Objects.hash(id, traceData);
+ }
+
+ @Override
+ @Nonnull
+ public String toString() {
+ return "MutableHashCodeRequest[" + id + ", " + traceData + "]";
+ }
+ }
+
+ static class MutableHashCodeResult implements
Result<MutableHashCodeRequest> {
+ private final MutableHashCodeRequest request;
+
+ MutableHashCodeResult(MutableHashCodeRequest request) {
+ this.request = request;
+ }
+
+ @Override
+ @Nonnull
+ public MutableHashCodeRequest getRequest() {
+ return request;
+ }
+ }
+
+ /**
+ * Cache implementation using identity-based storage (IdentityHashMap).
+ * Simulates real DefaultRequestCache behavior where request identity —
+ * not equals/hashCode — determines cache hits.
+ */
+ static class IdentityCachingTestRequestCache extends AbstractRequestCache {
+ private final java.util.IdentityHashMap<Object, CachingSupplier<?, ?>>
cache =
+ new java.util.IdentityHashMap<>();
+
+ @Override
+ @SuppressWarnings("unchecked")
+ protected <REQ extends Request<?>, REP extends Result<REQ>>
CachingSupplier<REQ, REP> doCache(
+ REQ req, Function<REQ, REP> supplier) {
+ return (CachingSupplier<REQ, REP>) cache.computeIfAbsent(req, r ->
new CachingSupplier<>(supplier));
+ }
+ }
+
static class TestRequestCache extends AbstractRequestCache {
private final java.util.Map<TestRequest, RuntimeException> failures =
new java.util.HashMap<>();