001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.rest.client.mcp;
018
019
020import java.util.*;
021import java.util.function.*;
022import static org.apache.juneau.commons.utils.Shorts.*;
023
024/**
025 * Built-in, process-local, in-memory default implementation of {@link McpResponseCache}.
026 *
027 * <p>
028 * Entries are partitioned by {@code scope}, so the same {@code key} in two different scopes never collides.
029 * A non-positive {@code ttlMs} passed to {@link #put(String, String, Object, long)} means the entry never expires.
030 *
031 * <h5 class='section'>Size bound / eviction policy:</h5>
032 * <p>
033 * The cache is bounded at {@link #DEFAULT_MAX_ENTRIES} total entries (across all scopes combined) by default,
034 * overridable via {@link #InMemoryMcpResponseCache(int)}. Once the cap is reached, inserting one more entry
035 * evicts the single <b>least-recently-used</b> entry (an access via {@link #get(String, String)}, whether a hit
036 * or a still-live entry, counts as a use; an expired-and-evicted-on-read entry does not) — a bounded default
037 * that never grows unboundedly under sustained cache traffic, without needing every caller to reason about a
038 * capacity setting themselves.
039 *
040 * <p>
041 * This class is thread-safe (a synchronized {@link LinkedHashMap} in access-order/LRU mode).
042 *
043 * @since 10.0.0
044 */
045@SuppressWarnings({
046   "java:S1192" // Duplicated literals (argument/property names) read more clearly inline than as constants
047})
048public class InMemoryMcpResponseCache implements McpResponseCache {
049
050   /**
051    * Default maximum number of entries (across all scopes combined) retained before the least-recently-used
052    * entry is evicted to make room for a new one. See the class javadoc's size-bound/eviction-policy note.
053    */
054   public static final int DEFAULT_MAX_ENTRIES = 10_000;
055
056   private record CacheEntry(Object value, long expiresAtMs) {}
057
058   private final Map<String,CacheEntry> entries;
059   private final LongSupplier clock;
060
061   /**
062    * Constructor. Uses the system clock ({@link System#currentTimeMillis()}) for TTL expiry and
063    * {@link #DEFAULT_MAX_ENTRIES} as the size cap.
064    */
065   public InMemoryMcpResponseCache() {
066      this(System::currentTimeMillis, DEFAULT_MAX_ENTRIES);
067   }
068
069   /**
070    * Constructor with an explicit size cap. Uses the system clock ({@link System#currentTimeMillis()}) for TTL
071    * expiry.
072    *
073    * @param maxEntries The maximum number of entries (across all scopes combined) retained before the
074    *    least-recently-used entry is evicted. Must be positive.
075    */
076   public InMemoryMcpResponseCache(int maxEntries) {
077      this(System::currentTimeMillis, maxEntries);
078   }
079
080   /**
081    * Constructor with an injectable clock, for deterministic TTL-expiry testing. Uses {@link #DEFAULT_MAX_ENTRIES}
082    * as the size cap.
083    *
084    * @param clock Supplies the current time in milliseconds. Must not be <jk>null</jk>.
085    */
086   InMemoryMcpResponseCache(LongSupplier clock) {
087      this(clock, DEFAULT_MAX_ENTRIES);
088   }
089
090   /**
091    * Constructor with both an injectable clock and an explicit size cap, for deterministic testing of the
092    * eviction policy.
093    *
094    * @param clock Supplies the current time in milliseconds. Must not be <jk>null</jk>.
095    * @param maxEntries The maximum number of entries (across all scopes combined) retained before the
096    *    least-recently-used entry is evicted. Must be positive.
097    */
098   InMemoryMcpResponseCache(LongSupplier clock, int maxEntries) {
099      this.clock = reqnn("clock", clock);
100      if (maxEntries <= 0)
101         throw new IllegalArgumentException("maxEntries must be positive: " + maxEntries);
102      this.entries = Collections.synchronizedMap(new LinkedHashMap<String,CacheEntry>(16, 0.75f, true) {
103         @Override protected boolean removeEldestEntry(Map.Entry<String,CacheEntry> eldest) {
104            return size() > maxEntries;
105         }
106      });
107   }
108
109   @Override /* Overridden from McpResponseCache */
110   public Optional<Object> get(String scope, String key) {
111      reqnn("scope", scope);
112      reqnn("key", key);
113      var partitionKey = partitionKey(scope, key);
114      synchronized (entries) {
115         var entry = entries.get(partitionKey);
116         if (entry == null)
117            return Optional.empty();
118         if (entry.expiresAtMs() > 0 && entry.expiresAtMs() <= clock.getAsLong()) {
119            entries.remove(partitionKey);
120            return Optional.empty();
121         }
122         return Optional.of(entry.value());
123      }
124   }
125
126   @Override /* Overridden from McpResponseCache */
127   public void put(String scope, String key, Object value, long ttlMs) {
128      reqnn("scope", scope);
129      reqnn("key", key);
130      reqnn("value", value);
131      var expiresAtMs = ttlMs > 0 ? clock.getAsLong() + ttlMs : 0L;
132      entries.put(partitionKey(scope, key), new CacheEntry(value, expiresAtMs));
133   }
134
135   @Override /* Overridden from McpResponseCache */
136   public void clear() {
137      entries.clear();
138   }
139
140   private static String partitionKey(String scope, String key) {
141      return scope + "\u0000" + key;
142   }
143}