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}