AbstractRequestCacheTest.java

/*
 * 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.maven.impl.cache;

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;
import org.apache.maven.api.annotations.Nonnull;
import org.apache.maven.api.cache.BatchRequestException;
import org.apache.maven.api.cache.RequestResult;
import org.apache.maven.api.services.Request;
import org.apache.maven.api.services.RequestTrace;
import org.apache.maven.api.services.Result;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;

class AbstractRequestCacheTest {

    private TestRequestCache cache;

    @BeforeEach
    void setUp() {
        cache = new TestRequestCache();
    }

    @Test
    void testBatchRequestExceptionIncludesSuppressedExceptions() {
        // Create mock requests and results
        TestRequest request1 = createTestRequest("request1");
        TestRequest request2 = createTestRequest("request2");
        TestRequest request3 = createTestRequest("request3");

        // Create specific exceptions with different messages and stack traces
        RuntimeException exception1 = new RuntimeException("Error processing request1");
        IllegalArgumentException exception2 = new IllegalArgumentException("Invalid argument in request2");
        IllegalStateException exception3 = new IllegalStateException("Invalid state in request3");

        // Set up the cache to return failures for all requests
        cache.addFailure(request1, exception1);
        cache.addFailure(request2, exception2);
        cache.addFailure(request3, exception3);

        List<TestRequest> requests = Arrays.asList(request1, request2, request3);

        // Create a supplier that should not be called since we're simulating cached failures
        Function<List<TestRequest>, List<TestResult>> supplier = reqs -> {
            throw new AssertionError("Supplier should not be called in this test");
        };

        // Execute the batch request and expect BatchRequestException
        BatchRequestException batchException =
                assertThrows(BatchRequestException.class, () -> cache.requests(requests, supplier));

        // Verify the main exception message
        assertEquals("One or more requests failed", batchException.getMessage());

        // Verify that all individual exceptions are included as suppressed exceptions
        Throwable[] suppressedExceptions = batchException.getSuppressed();
        assertNotNull(suppressedExceptions);
        assertEquals(3, suppressedExceptions.length);

        // Verify each suppressed exception
        assertTrue(Arrays.asList(suppressedExceptions).contains(exception1));
        assertTrue(Arrays.asList(suppressedExceptions).contains(exception2));
        assertTrue(Arrays.asList(suppressedExceptions).contains(exception3));

        // Verify the results contain the correct error information
        List<RequestResult<?, ?>> results = batchException.getResults();
        assertEquals(3, results.size());

        for (RequestResult<?, ?> result : results) {
            assertNotNull(result.error());
            assertInstanceOf(RuntimeException.class, result.error());
        }
    }

    @Test
    void testBatchRequestWithMixedSuccessAndFailure() {
        TestRequest successRequest = createTestRequest("success");
        TestRequest failureRequest = createTestRequest("failure");

        RuntimeException failureException = new RuntimeException("Processing failed");

        // Set up mixed success/failure scenario
        cache.addFailure(failureRequest, failureException);

        List<TestRequest> requests = Arrays.asList(successRequest, failureRequest);

        Function<List<TestRequest>, List<TestResult>> supplier = reqs -> {
            // Only the success request should reach the supplier
            assertEquals(1, reqs.size());
            assertEquals(successRequest, reqs.get(0));
            return List.of(new TestResult(successRequest));
        };

        BatchRequestException batchException =
                assertThrows(BatchRequestException.class, () -> cache.requests(requests, supplier));

        // Verify only the failure exception is suppressed
        Throwable[] suppressedExceptions = batchException.getSuppressed();
        assertEquals(1, suppressedExceptions.length);
        assertEquals(failureException, suppressedExceptions[0]);

        // Verify results: one success, one failure
        List<RequestResult<?, ?>> results = batchException.getResults();
        assertEquals(2, results.size());

        RequestResult<?, ?> result1 = results.get(0);
        RequestResult<?, ?> result2 = results.get(1);

        // One should be success, one should be failure
        boolean hasSuccess = (result1.error() == null) || (result2.error() == null);
        boolean hasFailure = (result1.error() != null) || (result2.error() != null);

        assertTrue(hasSuccess);
        assertTrue(hasFailure);
    }

    @Test
    void testSuccessfulBatchRequestDoesNotThrowException() {
        TestRequest request1 = createTestRequest("success1");
        TestRequest request2 = createTestRequest("success2");

        List<TestRequest> requests = Arrays.asList(request1, request2);

        Function<List<TestRequest>, List<TestResult>> supplier =
                reqs -> reqs.stream().map(TestResult::new).toList();

        // Should not throw any exception
        List<TestResult> results = cache.requests(requests, supplier);

        assertEquals(2, results.size());
        assertEquals(request1, results.get(0).getRequest());
        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) {
        ProtoSession session = mock(ProtoSession.class);
        return new TestRequestImpl(id, session);
    }

    // Test implementations

    interface TestRequest extends Request<ProtoSession> {}

    static class TestRequestImpl implements TestRequest {
        private final String id;
        private final ProtoSession session;

        TestRequestImpl(String id, ProtoSession session) {
            this.id = id;
            this.session = session;
        }

        @Override
        @Nonnull
        public ProtoSession getSession() {
            return session;
        }

        @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;
            }
            TestRequestImpl that = (TestRequestImpl) obj;
            return java.util.Objects.equals(id, that.id);
        }

        @Override
        public int hashCode() {
            return java.util.Objects.hash(id);
        }

        @Override
        @Nonnull
        public String toString() {
            return "TestRequest[" + id + "]";
        }
    }

    static class TestResult implements Result<TestRequest> {
        private final TestRequest request;

        TestResult(TestRequest request) {
            this.request = request;
        }

        @Override
        @Nonnull
        public TestRequest getRequest() {
            return request;
        }
    }

    /**
     * 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<>();

        void addFailure(TestRequest request, RuntimeException exception) {
            failures.put(request, exception);
        }

        public CacheStatistics getStatistics() {
            return null; // Not implemented for test
        }

        @Override
        protected <REQ extends Request<?>, REP extends Result<REQ>> CachingSupplier<REQ, REP> doCache(
                REQ req, Function<REQ, REP> supplier) {
            // Check if we have a pre-configured failure for this request
            RuntimeException failure = failures.get(req);
            if (failure != null) {
                // Return a pre-cached failure by creating a supplier that always throws
                return new PreCachedFailureCachingSupplier<>(failure);
            }

            // For non-failure cases, return a normal caching supplier
            return new CachingSupplier<>(supplier);
        }

        // Custom CachingSupplier that simulates a pre-cached failure
        private static class PreCachedFailureCachingSupplier<REQ, REP> extends CachingSupplier<REQ, REP> {
            PreCachedFailureCachingSupplier(RuntimeException failure) {
                super(null); // No supplier needed
                // Pre-populate the value with the failure
                this.value = new AltRes(failure);
            }
        }
    }
}