This is an automated email from the ASF dual-hosted git repository.

jamesfredley pushed a commit to branch feat/caffeine-cache-migration-seed
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit e01d6d8f766b75a5cfb740267f4e883df6cb5fa6
Author: James Fredley <[email protected]>
AuthorDate: Fri Jul 10 12:58:01 2026 -0400

    Migrate concurrent linked map cache to Caffeine
    
    Replace concurrentlinkedhashmap-lru internals with Caffeine while keeping 
public cache API.
    
    Assisted-by: Sisyphus:xai/grok-4.5 [gpt-coding]
---
 grails-cache/build.gradle                          |   2 +-
 .../cache/GrailsConcurrentLinkedMapCache.java      |  27 ++--
 .../GrailsConcurrentLinkedMapCacheManager.groovy   |   2 +-
 .../GrailsConcurrentLinkedMapCacheTests.groovy     | 151 +++++++++++++--------
 .../guide/cache/cacheUsage/cacheConfiguration.adoc |   3 +-
 5 files changed, 112 insertions(+), 73 deletions(-)

diff --git a/grails-cache/build.gradle b/grails-cache/build.gradle
index 5534081f00..26a70a9ecf 100644
--- a/grails-cache/build.gradle
+++ b/grails-cache/build.gradle
@@ -47,7 +47,7 @@ dependencies {
 
     api project(':grails-gsp')
     api "org.codehaus.gpars:gpars:$gparsVersion"
-    api 
"com.googlecode.concurrentlinkedhashmap:concurrentlinkedhashmap-lru:$concurrentlinkedhashmapLruVersion"
+    api 'com.github.ben-manes.caffeine:caffeine'
 
     compileOnly project(':grails-domain-class')
 
diff --git 
a/grails-cache/src/main/groovy/grails/plugin/cache/GrailsConcurrentLinkedMapCache.java
 
b/grails-cache/src/main/groovy/grails/plugin/cache/GrailsConcurrentLinkedMapCache.java
index efa156ab10..471492d3c0 100644
--- 
a/grails-cache/src/main/groovy/grails/plugin/cache/GrailsConcurrentLinkedMapCache.java
+++ 
b/grails-cache/src/main/groovy/grails/plugin/cache/GrailsConcurrentLinkedMapCache.java
@@ -20,10 +20,13 @@ package grails.plugin.cache;
 
 import java.io.Serializable;
 import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.Optional;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ConcurrentMap;
 
-import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
 
 import org.springframework.cache.support.SimpleValueWrapper;
 
@@ -35,7 +38,8 @@ public class GrailsConcurrentLinkedMapCache implements 
GrailsCache {
     private static final Object NULL_HOLDER = new NullHolder();
     private String name;
     private long capacity;
-    private final ConcurrentLinkedHashMap<Object, Object> store;
+    private final Cache<Object, Object> caffeineStore;
+    private final ConcurrentMap<Object, Object> store;
     private final boolean allowNullValues;
 
     /**
@@ -49,11 +53,10 @@ public class GrailsConcurrentLinkedMapCache implements 
GrailsCache {
         this.name = name;
         this.capacity = capacity;
         this.allowNullValues = allowNullValues;
-        // Workaround: using explicit type arguments to prevent groovydoc 
error (#53)
-        // Replace with diamond operator once a fix for GROOVY-8628 is 
included in groovy dependency
-        this.store = new ConcurrentLinkedHashMap.Builder<>()
-            .maximumWeightedCapacity(capacity)
+        this.caffeineStore = Caffeine.newBuilder()
+            .maximumSize(capacity)
             .build();
+        this.store = caffeineStore.asMap();
     }
 
     /**
@@ -67,7 +70,7 @@ public class GrailsConcurrentLinkedMapCache implements 
GrailsCache {
     }
 
     public final long getCapacity() {
-        return this.store.capacity();
+        return this.capacity;
     }
 
     @Override
@@ -81,6 +84,7 @@ public class GrailsConcurrentLinkedMapCache implements 
GrailsCache {
     }
 
     public final int getSize() {
+        this.caffeineStore.cleanUp();
         return this.store.size();
     }
 
@@ -114,7 +118,12 @@ public class GrailsConcurrentLinkedMapCache implements 
GrailsCache {
     }
 
     public Collection<Object> getHottestKeys() {
-        return this.store.descendingKeySet();
+        Optional<com.github.benmanes.caffeine.cache.Policy.Eviction<Object, 
Object>> eviction = this.caffeineStore.policy().eviction();
+        if (eviction.isPresent()) {
+            int limit = capacity > Integer.MAX_VALUE ? Integer.MAX_VALUE : 
(int) capacity;
+            return new LinkedHashSet<>(eviction.get().hottest(limit).keySet());
+        }
+        return this.store.keySet();
     }
 
     @Override
@@ -123,7 +132,7 @@ public class GrailsConcurrentLinkedMapCache implements 
GrailsCache {
     }
 
     public ValueWrapper putIfAbsent(Object key, Object value) {
-        Object existing = this.store.putIfAbsent(key, value);
+        Object existing = this.store.putIfAbsent(key, toStoreValue(value));
         return toWrapper(existing);
     }
 
diff --git 
a/grails-cache/src/main/groovy/grails/plugin/cache/GrailsConcurrentLinkedMapCacheManager.groovy
 
b/grails-cache/src/main/groovy/grails/plugin/cache/GrailsConcurrentLinkedMapCacheManager.groovy
index 2a73bf73a7..55012c8d64 100644
--- 
a/grails-cache/src/main/groovy/grails/plugin/cache/GrailsConcurrentLinkedMapCacheManager.groovy
+++ 
b/grails-cache/src/main/groovy/grails/plugin/cache/GrailsConcurrentLinkedMapCacheManager.groovy
@@ -28,7 +28,7 @@ import org.springframework.cache.Cache
 import org.grails.plugin.cache.GrailsCacheManager
 
 /**
- * Based on com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap.
+ * Creates bounded Grails cache instances.
  *
  * @author Jakob Drangmeister
  */
diff --git 
a/grails-cache/src/test/groovy/grails/plugin/cache/GrailsConcurrentLinkedMapCacheTests.groovy
 
b/grails-cache/src/test/groovy/grails/plugin/cache/GrailsConcurrentLinkedMapCacheTests.groovy
index 3f27165849..a007d64875 100644
--- 
a/grails-cache/src/test/groovy/grails/plugin/cache/GrailsConcurrentLinkedMapCacheTests.groovy
+++ 
b/grails-cache/src/test/groovy/grails/plugin/cache/GrailsConcurrentLinkedMapCacheTests.groovy
@@ -18,107 +18,138 @@
  */
 package grails.plugin.cache
 
+import java.util.concurrent.ConcurrentMap
+
 import org.springframework.cache.support.SimpleValueWrapper
-import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap
-import org.junit.Test
+import spock.lang.Specification
 
 /**
  * @author Jakob Drangmeister
  */
-class GrailsConcurrentLinkedMapCacheTests {
-
-    @Test
-   void testCreateCache() {
-      GrailsConcurrentLinkedMapCache smallCache = new 
GrailsConcurrentLinkedMapCache("smallCache", 1000)
+class GrailsConcurrentLinkedMapCacheTests extends Specification {
+
+   void 'creates caches with configured capacity and null value policy'() {
+      when:
+      GrailsConcurrentLinkedMapCache smallCache = new 
GrailsConcurrentLinkedMapCache('smallCache', 1000)
+
+      then:
+      smallCache.name == 'smallCache'
+      smallCache.nativeCache instanceof ConcurrentMap
+      smallCache.capacity == 1000
+      smallCache.allowNullValues
+
+      when:
+      GrailsConcurrentLinkedMapCache bigCache = new 
GrailsConcurrentLinkedMapCache('bigCache', 5000000, false)
+
+      then:
+      bigCache.name == 'bigCache'
+      bigCache.nativeCache instanceof ConcurrentMap
+      bigCache.capacity == 5000000
+      !bigCache.allowNullValues
+   }
 
-      assert smallCache.getName() == "smallCache"
-      assert smallCache.getNativeCache() instanceof ConcurrentLinkedHashMap
-      assert smallCache.getCapacity() == 1000
-      assert smallCache.isAllowNullValues() == true
+   void 'exposes Caffeine native cache as concurrent map'() {
+      given:
+      GrailsConcurrentLinkedMapCache cache = new 
GrailsConcurrentLinkedMapCache('cache', 10, true)
 
-      GrailsConcurrentLinkedMapCache bigCache = new 
GrailsConcurrentLinkedMapCache("bigCache", 5000000, false)
+      when:
+      cache.put("key", "value")
 
-      assert bigCache.getName() == "bigCache"
-      assert bigCache.getNativeCache() instanceof ConcurrentLinkedHashMap
-      assert bigCache.getCapacity() == 5000000
-      assert bigCache.isAllowNullValues() == false
+      then:
+      cache.nativeCache.get('key') == 'value'
+      
cache.nativeCache.getClass().name.startsWith('com.github.benmanes.caffeine.cache.')
    }
 
-   @Test
-   void testPutAndGet() {
-      GrailsConcurrentLinkedMapCache cache = new 
GrailsConcurrentLinkedMapCache("cache", 1000, true)
+   void 'puts and gets cache entries'() {
+      given:
+      GrailsConcurrentLinkedMapCache cache = new 
GrailsConcurrentLinkedMapCache('cache', 1000, true)
 
-      cache.put("key", "value");
+      when:
+      cache.put('key', 'value')
 
-      assert cache.getSize() == 1
+      then:
+      cache.size == 1
       GrailsValueWrapper value = cache.get("key")
-      assert value.get().equals("value")
+      value.get() == 'value'
    }
 
-   @Test
-   void testPutIfAbsent() {
-      GrailsConcurrentLinkedMapCache cache = new 
GrailsConcurrentLinkedMapCache("cache", 1000, true)
-      cache.put("key", "value")
-      cache.putIfAbsent("key", "value") instanceof SimpleValueWrapper
-      assert cache.getSize() == 1
+   void 'putIfAbsent keeps existing cache value'() {
+      given:
+      GrailsConcurrentLinkedMapCache cache = new 
GrailsConcurrentLinkedMapCache('cache', 1000, true)
+      cache.put('key', 'value')
+
+      expect:
+      cache.putIfAbsent('key', 'value') instanceof SimpleValueWrapper
+      cache.size == 1
    }
 
-   @Test
-   void testEvict() {
-      GrailsConcurrentLinkedMapCache cache = new 
GrailsConcurrentLinkedMapCache("cache", 10, true)
-      cache.put("key", "value");
-      assert cache.getSize() == 1
+   void 'evicts cache entries'() {
+      given:
+      GrailsConcurrentLinkedMapCache cache = new 
GrailsConcurrentLinkedMapCache('cache', 10, true)
+      cache.put('key', 'value')
 
-      cache.evict("key")
-      assert cache.getSize() == 0
+      expect:
+      cache.size == 1
 
+      when:
+      cache.evict('key')
+
+      then:
+      cache.size == 0
    }
 
-   @Test
-   void testCacheCapacity() {
-      GrailsConcurrentLinkedMapCache cache = new 
GrailsConcurrentLinkedMapCache("cache", 1000, true)
-      assert cache.getCapacity() == 1000
+   void 'limits cache size to configured capacity'() {
+      given:
+      GrailsConcurrentLinkedMapCache cache = new 
GrailsConcurrentLinkedMapCache('cache', 1000, true)
 
-      for(int i = 0; i < 2000; i++) {
-         cache.put(i, i)
+      when:
+      for (int i = 0; i < 2000; i++) {
+          cache.put(i, i)
       }
 
-      assert cache.getSize() == 1000
+      then:
+      cache.capacity == 1000
+      cache.size == 1000
    }
 
-   @Test
-   void testCacheGetHottestKeys() {
-      GrailsConcurrentLinkedMapCache cache = new 
GrailsConcurrentLinkedMapCache("cache", 10, true)
+   void 'returns hottest keys from cache eviction policy'() {
+      given:
+      GrailsConcurrentLinkedMapCache cache = new 
GrailsConcurrentLinkedMapCache('cache', 10, true)
 
-      for(int i = 0; i < 10; i++) {
-         cache.put(i, i);
+      for (int i = 0; i < 10; i++) {
+         cache.put(i, i)
       }
 
+      when:
       cache.get(1)
       cache.get(2)
 
-      assert cache.getHottestKeys()[0] == 2
-      assert cache.getHottestKeys()[1] == 1
+      then:
+      cache.hottestKeys.containsAll([1, 2])
 
-      for(int i = 10; i < 19; i++) {
-         cache.put(i, i);
+      when:
+      for (int i = 10; i < 19; i++) {
+         cache.put(i, i)
       }
 
-      assert cache.getHottestKeys()[cache.getSize()-1] == 2
-
+      then:
+      cache.hottestKeys.contains(2)
    }
 
-   @Test
-   void testClear() {
-      GrailsConcurrentLinkedMapCache cache = new 
GrailsConcurrentLinkedMapCache("cache", 1000, true)
-      assert cache.getCapacity() == 1000
+   void 'clears all cache entries'() {
+      given:
+      GrailsConcurrentLinkedMapCache cache = new 
GrailsConcurrentLinkedMapCache('cache', 1000, true)
+      cache.put('key', 'value')
 
-      cache.put("key", "value")
-      assert cache.getSize() == 1
+      expect:
+      cache.capacity == 1000
+      cache.size == 1
 
+      when:
       cache.clear()
 
-      assert cache.getSize() == 0
+      then:
+      cache.size == 0
    }
 
 }
diff --git a/grails-doc/src/en/guide/cache/cacheUsage/cacheConfiguration.adoc 
b/grails-doc/src/en/guide/cache/cacheUsage/cacheConfiguration.adoc
index fce48ea547..7ba9a4ca51 100644
--- a/grails-doc/src/en/guide/cache/cacheUsage/cacheConfiguration.adoc
+++ b/grails-doc/src/en/guide/cache/cacheUsage/cacheConfiguration.adoc
@@ -28,7 +28,7 @@ There are a few configuration options for the plugin; these 
are specified in
 *Property*,*Default*,*Description*
 grails.cache.enabled,`true`,Whether to enable the plugin
 grails.cache.clearAtStartup,`false`,Whether to clear all caches at startup
-grails.cache.cacheManager,GrailsConcurrentMapCacheManager,Cache Manager to 
use. Default cache manager uses Spring Frameworks ConcurrentMapCache which 
might grow limitless. If you cannot predict how many cache entries you are 
going to generate use "GrailsConcurrentLinkedMapCacheManager" instead which 
uses com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap and limits 
by default to 10000 entries per cache.
+grails.cache.cacheManager,GrailsConcurrentMapCacheManager,Cache Manager to 
use. Default cache manager uses Spring Frameworks ConcurrentMapCache which 
might grow limitless. If you cannot predict how many cache entries you are 
going to generate use "GrailsConcurrentLinkedMapCacheManager" instead. In 
Grails 8.1 this manager keeps the public Grails cache API while using 
Caffeine-backed bounded caches; the old concurrentlinkedhashmap-lru dependency 
path is deprecated.
 |===
 
 The cache implementation used by this plugin is very simple, so there aren't
@@ -61,4 +61,3 @@ grails:
             maps:
                 maxCapacity: 6000
 ----
-

Reply via email to