DefaultRequestCache.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.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.function.Function;

import org.apache.maven.api.Constants;
import org.apache.maven.api.Session;
import org.apache.maven.api.SessionData;
import org.apache.maven.api.cache.CacheMetadata;
import org.apache.maven.api.cache.CacheRetention;
import org.apache.maven.api.services.Request;
import org.apache.maven.api.services.RequestTrace;
import org.apache.maven.api.services.Result;
import org.apache.maven.impl.InternalSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class DefaultRequestCache extends AbstractRequestCache {

    private static final Logger LOGGER = LoggerFactory.getLogger(DefaultRequestCache.class);

    protected static final SessionData.Key<Cache> KEY = SessionData.key(Cache.class, CacheMetadata.class);
    protected static final Object ROOT = new Object();

    // Comprehensive cache statistics
    private final CacheStatistics statistics = new CacheStatistics();

    private static volatile boolean shutdownHookRegistered = false;
    private static final List<CacheStatistics> ALL_STATISTICS = new ArrayList<CacheStatistics>();

    // Synchronized method to ensure shutdown hook is registered only once
    private static synchronized void ensureShutdownHookRegistered() {
        if (!shutdownHookRegistered) {
            Runtime.getRuntime()
                    .addShutdownHook(new Thread(
                            () -> {
                                // Check if cache stats should be displayed
                                for (CacheStatistics statistics : ALL_STATISTICS) {
                                    if (statistics.getTotalRequests() > 0) {
                                        System.err.println("[INFO] " + formatCacheStatistics(statistics));
                                    }
                                }
                            },
                            "DefaultRequestCache-Statistics"));
            shutdownHookRegistered = true;
        }
    }

    public DefaultRequestCache() {
        // Register cache size suppliers for different retention policies
        // Note: These provide approximate sizes since the improved cache architecture
        // uses distributed caches across sessions
        statistics.registerCacheSizeSupplier(CacheRetention.PERSISTENT, () -> 0L);
        statistics.registerCacheSizeSupplier(CacheRetention.SESSION_SCOPED, () -> 0L);
        statistics.registerCacheSizeSupplier(CacheRetention.REQUEST_SCOPED, () -> 0L);

        synchronized (ALL_STATISTICS) {
            ALL_STATISTICS.add(statistics);
        }
    }

    /**
     * Formats comprehensive cache statistics for display.
     *
     * @param stats the cache statistics to format
     * @return a formatted string containing cache statistics
     */
    static String formatCacheStatistics(CacheStatistics stats) {
        StringBuilder sb = new StringBuilder();
        sb.append("Request Cache Statistics:\n");
        sb.append("  Total requests: ").append(stats.getTotalRequests()).append("\n");
        sb.append("  Cache hits: ").append(stats.getCacheHits()).append("\n");
        sb.append("  Cache misses: ").append(stats.getCacheMisses()).append("\n");
        sb.append("  Hit ratio: ")
                .append(String.format(Locale.ENGLISH, "%.2f%%", stats.getHitRatio()))
                .append("\n");

        // Show eviction statistics
        long totalEvictions = stats.getTotalEvictions();
        if (totalEvictions > 0) {
            sb.append("  Evictions:\n");
            sb.append("    Key evictions: ")
                    .append(stats.getKeyEvictions())
                    .append(" (")
                    .append(String.format(Locale.ENGLISH, "%.1f%%", stats.getKeyEvictionRatio()))
                    .append(")\n");
            sb.append("    Value evictions: ")
                    .append(stats.getValueEvictions())
                    .append(" (")
                    .append(String.format(Locale.ENGLISH, "%.1f%%", stats.getValueEvictionRatio()))
                    .append(")\n");
            sb.append("    Total evictions: ").append(totalEvictions).append("\n");
        }

        // Show retention policy breakdown
        var retentionStats = stats.getRetentionStatistics();
        if (!retentionStats.isEmpty()) {
            sb.append("  By retention policy:\n");
            retentionStats.forEach((retention, retStats) -> {
                sb.append("    ")
                        .append(retention)
                        .append(": ")
                        .append(retStats.getHits())
                        .append(" hits, ")
                        .append(retStats.getMisses())
                        .append(" misses (")
                        .append(String.format(Locale.ENGLISH, "%.1f%%", retStats.getHitRatio()))
                        .append(" hit ratio)");

                // Add eviction info for this retention policy
                long retKeyEvictions = retStats.getKeyEvictions();
                long retValueEvictions = retStats.getValueEvictions();
                if (retKeyEvictions > 0 || retValueEvictions > 0) {
                    sb.append(", ")
                            .append(retKeyEvictions)
                            .append(" key evictions, ")
                            .append(retValueEvictions)
                            .append(" value evictions");
                }
                sb.append("\n");
            });
        }

        // Show reference type statistics
        var refTypeStats = stats.getReferenceTypeStatistics();
        if (!refTypeStats.isEmpty()) {
            sb.append("  Reference type usage:\n");
            refTypeStats.entrySet().stream()
                    .sorted((e1, e2) ->
                            Long.compare(e2.getValue().getTotal(), e1.getValue().getTotal()))
                    .forEach(entry -> {
                        var refStats = entry.getValue();
                        sb.append("    ")
                                .append(entry.getKey())
                                .append(": ")
                                .append(refStats.getCacheCreations())
                                .append(" caches, ")
                                .append(refStats.getTotal())
                                .append(" accesses (")
                                .append(String.format(Locale.ENGLISH, "%.1f%%", refStats.getHitRatio()))
                                .append(" hit ratio)\n");
                    });
        }

        // Show top request types
        var requestStats = stats.getRequestTypeStatistics();
        if (!requestStats.isEmpty()) {
            sb.append("  Top request types:\n");
            requestStats.entrySet().stream()
                    .sorted((e1, e2) ->
                            Long.compare(e2.getValue().getTotal(), e1.getValue().getTotal()))
                    // .limit(5)
                    .forEach(entry -> {
                        var reqStats = entry.getValue();
                        sb.append("    ")
                                .append(entry.getKey())
                                .append(": ")
                                .append(reqStats.getTotal())
                                .append(" requests (")
                                .append(String.format(Locale.ENGLISH, "%.1f%%", reqStats.getHitRatio()))
                                .append(" hit ratio)\n");
                    });
        }

        return sb.toString();
    }

    public CacheStatistics getStatistics() {
        return statistics;
    }

    @Override
    @SuppressWarnings({"unchecked", "checkstyle:MethodLength"})
    protected <REQ extends Request<?>, REP extends Result<REQ>> CachingSupplier<REQ, REP> doCache(
            REQ req, Function<REQ, REP> supplier) {
        // Early return for non-Session requests (e.g., ProtoSession)
        if (!(req.getSession() instanceof Session session)) {
            // Record as a miss since no caching is performed for non-Session requests
            statistics.recordMiss(req.getClass().getSimpleName(), CacheRetention.DISABLED);
            return new CachingSupplier<>(supplier);
        }

        // Register shutdown hook for conditional statistics display
        boolean cacheStatsEnabled = isCacheStatsEnabled(session);
        if (cacheStatsEnabled) {
            ensureShutdownHookRegistered();
        }

        CacheConfig config = getCacheConfig(req, session);
        CacheRetention retention = config.scope();
        Cache.ReferenceType referenceType = config.referenceType();
        Cache.ReferenceType keyReferenceType = config.getEffectiveKeyReferenceType();
        Cache.ReferenceType valueReferenceType = config.getEffectiveValueReferenceType();

        // Debug logging to verify reference types (disabled)
        // System.err.println("DEBUG: Cache config for " + req.getClass().getSimpleName() + ": retention=" + retention
        //         + ", keyRef=" + keyReferenceType + ", valueRef=" + valueReferenceType);
        if (LOGGER.isDebugEnabled()) {
            LOGGER.debug(
                    "Cache config for {}: retention={}, keyRef={}, valueRef={}",
                    req.getClass().getSimpleName(),
                    retention,
                    keyReferenceType,
                    valueReferenceType);
        }

        // Handle disabled caching
        if (retention == CacheRetention.DISABLED
                || keyReferenceType == Cache.ReferenceType.NONE
                || valueReferenceType == Cache.ReferenceType.NONE) {
            // Record as a miss since no caching is performed
            statistics.recordMiss(req.getClass().getSimpleName(), retention);
            return new CachingSupplier<>(supplier);
        }

        Cache<Object, CachingSupplier<?, ?>> cache = null;
        String cacheType = "NONE";

        if (retention == CacheRetention.SESSION_SCOPED) {
            Cache<Object, Cache<Object, CachingSupplier<?, ?>>> caches = session.getData()
                    .computeIfAbsent(KEY, () -> {
                        if (config.hasSeparateKeyValueReferenceTypes()) {
                            LOGGER.debug(
                                    "Creating SESSION_SCOPED parent cache with key={}, value={}",
                                    keyReferenceType,
                                    valueReferenceType);
                            return Cache.newCache(keyReferenceType, valueReferenceType, "RequestCache-SESSION-Parent");
                        } else {
                            return Cache.newCache(Cache.ReferenceType.SOFT, "RequestCache-SESSION-Parent");
                        }
                    });

            // Use separate key/value reference types if configured
            if (config.hasSeparateKeyValueReferenceTypes()) {
                cache = caches.computeIfAbsent(ROOT, k -> {
                    LOGGER.debug(
                            "Creating SESSION_SCOPED cache with key={}, value={}",
                            keyReferenceType,
                            valueReferenceType);
                    Cache<Object, CachingSupplier<?, ?>> newCache =
                            Cache.newCache(keyReferenceType, valueReferenceType, "RequestCache-SESSION");
                    statistics.recordCacheCreation(
                            keyReferenceType.toString(), valueReferenceType.toString(), retention);
                    setupEvictionListenerIfNeeded(newCache, retention);

                    // Debug logging to verify actual reference types (disabled)
                    // if (newCache instanceof Cache.RefConcurrentMap<?, ?> refMap) {
                    //     System.err.println("DEBUG: Created cache '" + refMap.getName() + "' - requested key="
                    //             + keyReferenceType
                    //             + ", value=" + valueReferenceType + ", actual key=" + refMap.getKeyReferenceType()
                    //             + ", actual value=" + refMap.getValueReferenceType());
                    // }
                    return newCache;
                });
            } else {
                cache = caches.computeIfAbsent(ROOT, k -> {
                    Cache<Object, CachingSupplier<?, ?>> newCache =
                            Cache.newCache(referenceType, "RequestCache-SESSION");
                    statistics.recordCacheCreation(referenceType.toString(), referenceType.toString(), retention);
                    setupEvictionListenerIfNeeded(newCache, retention);
                    return newCache;
                });
            }
            cacheType = "SESSION_SCOPED";
            // Debug logging for cache sizes
            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug(
                        "Cache access: type={}, request={}, cacheSize={}, totalCaches={}, key={}",
                        cacheType,
                        req.getClass().getSimpleName(),
                        cache.size(),
                        caches.size(),
                        ROOT);
            }
        } else if (retention == CacheRetention.REQUEST_SCOPED) {
            Object key = doGetOuterRequest(req);
            Cache<Object, Cache<Object, CachingSupplier<?, ?>>> caches = session.getData()
                    .computeIfAbsent(KEY, () -> {
                        if (config.hasSeparateKeyValueReferenceTypes()) {
                            LOGGER.debug(
                                    "Creating REQUEST_SCOPED parent cache with key={}, value={}",
                                    keyReferenceType,
                                    valueReferenceType);
                            return Cache.newCache(keyReferenceType, valueReferenceType, "RequestCache-REQUEST-Parent");
                        } else {
                            return Cache.newCache(Cache.ReferenceType.SOFT, "RequestCache-REQUEST-Parent");
                        }
                    });

            // Use separate key/value reference types if configured
            if (config.hasSeparateKeyValueReferenceTypes()) {
                cache = caches.computeIfAbsent(key, k -> {
                    LOGGER.debug(
                            "Creating REQUEST_SCOPED cache with key={}, value={}",
                            keyReferenceType,
                            valueReferenceType);
                    Cache<Object, CachingSupplier<?, ?>> newCache =
                            Cache.newCache(keyReferenceType, valueReferenceType, "RequestCache-REQUEST");
                    statistics.recordCacheCreation(
                            keyReferenceType.toString(), valueReferenceType.toString(), retention);
                    setupEvictionListenerIfNeeded(newCache, retention);
                    return newCache;
                });
            } else {
                cache = caches.computeIfAbsent(key, k -> {
                    Cache<Object, CachingSupplier<?, ?>> newCache =
                            Cache.newCache(referenceType, "RequestCache-REQUEST");
                    statistics.recordCacheCreation(referenceType.toString(), referenceType.toString(), retention);
                    setupEvictionListenerIfNeeded(newCache, retention);
                    return newCache;
                });
            }
            cacheType = "REQUEST_SCOPED";

            // Debug logging for cache sizes
            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug(
                        "Cache access: type={}, request={}, cacheSize={}, totalCaches={}, key={}",
                        cacheType,
                        req.getClass().getSimpleName(),
                        cache.size(),
                        caches.size(),
                        key.getClass().getSimpleName());
            }

        } else if (retention == CacheRetention.PERSISTENT) {
            Cache<Object, Cache<Object, CachingSupplier<?, ?>>> caches = session.getData()
                    .computeIfAbsent(KEY, () -> {
                        if (config.hasSeparateKeyValueReferenceTypes()) {
                            LOGGER.debug(
                                    "Creating PERSISTENT parent cache with key={}, value={}",
                                    keyReferenceType,
                                    valueReferenceType);
                            return Cache.newCache(
                                    keyReferenceType, valueReferenceType, "RequestCache-PERSISTENT-Parent");
                        } else {
                            return Cache.newCache(Cache.ReferenceType.SOFT, "RequestCache-PERSISTENT-Parent");
                        }
                    });

            // Use separate key/value reference types if configured
            if (config.hasSeparateKeyValueReferenceTypes()) {
                cache = caches.computeIfAbsent(KEY, k -> {
                    LOGGER.debug(
                            "Creating PERSISTENT cache with key={}, value={}", keyReferenceType, valueReferenceType);
                    Cache<Object, CachingSupplier<?, ?>> newCache =
                            Cache.newCache(keyReferenceType, valueReferenceType, "RequestCache-PERSISTENT");
                    statistics.recordCacheCreation(
                            keyReferenceType.toString(), valueReferenceType.toString(), retention);
                    setupEvictionListenerIfNeeded(newCache, retention);
                    return newCache;
                });
            } else {
                cache = caches.computeIfAbsent(KEY, k -> {
                    Cache<Object, CachingSupplier<?, ?>> newCache =
                            Cache.newCache(referenceType, "RequestCache-PERSISTENT");
                    statistics.recordCacheCreation(referenceType.toString(), referenceType.toString(), retention);
                    setupEvictionListenerIfNeeded(newCache, retention);
                    return newCache;
                });
            }
            cacheType = "PERSISTENT";

            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug(
                        "Cache access: type={}, request={}, cacheSize={}",
                        cacheType,
                        req.getClass().getSimpleName(),
                        cache.size());
            }
        }

        if (cache != null) {
            // Set up eviction listener if this is a RefConcurrentMap
            setupEvictionListenerIfNeeded(cache, retention);

            boolean isNewEntry = !cache.containsKey(req);
            CachingSupplier<REQ, REP> result = (CachingSupplier<REQ, REP>)
                    cache.computeIfAbsent(req, r -> new CachingSupplier<>(supplier), referenceType);

            // Record statistics using the comprehensive system
            String requestType = req.getClass().getSimpleName();

            // Record reference type statistics
            if (cache instanceof Cache.RefConcurrentMap<?, ?> refMap) {
                statistics.recordCacheAccess(
                        refMap.getKeyReferenceType().toString(),
                        refMap.getValueReferenceType().toString(),
                        !isNewEntry);
            }

            if (isNewEntry) {
                statistics.recordMiss(requestType, retention);
                if (LOGGER.isTraceEnabled()) {
                    LOGGER.trace(
                            "Cache MISS: type={}, request={}, newCacheSize={}", cacheType, requestType, cache.size());
                }
            } else {
                statistics.recordHit(requestType, retention);
                if (LOGGER.isTraceEnabled()) {
                    LOGGER.trace("Cache HIT: type={}, request={}", cacheType, requestType);
                }
            }
            return result;
        } else {
            // Record as a miss since no cache was available
            statistics.recordMiss(req.getClass().getSimpleName(), retention);
            if (LOGGER.isTraceEnabled()) {
                LOGGER.trace("No cache: request={}", req.getClass().getSimpleName());
            }
            return new CachingSupplier<>(supplier);
        }
    }

    private static boolean isCacheStatsEnabled(Session session) {
        String showStats = session.getUserProperties().get(Constants.MAVEN_CACHE_STATS);
        return Boolean.parseBoolean(showStats);
    }

    /**
     * Sets up eviction listener for the cache if it's a RefConcurrentMap.
     * This avoids memory leaks by having the cache push events to statistics
     * instead of statistics holding references to caches.
     */
    private void setupEvictionListenerIfNeeded(Cache<Object, CachingSupplier<?, ?>> cache, CacheRetention retention) {
        if (cache instanceof Cache.RefConcurrentMap<?, ?> refMap) {
            // Set up the eviction listener (it's safe to set multiple times)
            refMap.setEvictionListener(new Cache.EvictionListener() {
                @Override
                public void onKeyEviction() {
                    statistics.recordKeyEviction(retention);
                }

                @Override
                public void onValueEviction() {
                    statistics.recordValueEviction(retention);
                }
            });
        }
    }

    private <REQ extends Request<?>> Object doGetOuterRequest(REQ req) {
        RequestTrace trace = req.getTrace();
        if (trace == null && req.getSession() instanceof Session session) {
            trace = InternalSession.from(session).getCurrentTrace();
        }
        while (trace != null && trace.parent() != null) {
            trace = trace.parent();
        }
        return trace != null && trace.data() != null ? trace.data() : req;
    }

    /**
     * Gets the cache configuration for the given request and session.
     *
     * @param req the request to get configuration for
     * @param session the session containing user properties
     * @return the resolved cache configuration
     */
    private <REQ extends Request<?>> CacheConfig getCacheConfig(REQ req, Session session) {
        return CacheConfigurationResolver.resolveConfig(req, session);
    }
}