From 3e3b3522157509e3baa129592c3e088790cc386c Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Fri, 14 Nov 2025 10:33:37 +0100 Subject: [PATCH] Introduce Caffeine-based cache backend with JUnit 5 tests. Depend on Caffeine 2.9.3 (Java 8 compatible). --- httpclient5-cache/pom.xml | 5 + .../caffeine/CaffeineHttpCacheStorage.java | 146 ++++++++++++++ .../TestCaffeineHttpCacheStorage.java | 187 ++++++++++++++++++ pom.xml | 6 + 4 files changed, 344 insertions(+) create mode 100644 httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/caffeine/CaffeineHttpCacheStorage.java create mode 100644 httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/caffeine/TestCaffeineHttpCacheStorage.java diff --git a/httpclient5-cache/pom.xml b/httpclient5-cache/pom.xml index e1d30f756f..4cce941387 100644 --- a/httpclient5-cache/pom.xml +++ b/httpclient5-cache/pom.xml @@ -91,6 +91,11 @@ junit-jupiter test + + com.github.ben-manes.caffeine + caffeine + true + diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/caffeine/CaffeineHttpCacheStorage.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/caffeine/CaffeineHttpCacheStorage.java new file mode 100644 index 0000000000..293c092f57 --- /dev/null +++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/caffeine/CaffeineHttpCacheStorage.java @@ -0,0 +1,146 @@ +/* + * ==================================================================== + * 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.impl.cache.caffeine; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import com.github.benmanes.caffeine.cache.Cache; + +import org.apache.hc.client5.http.cache.HttpCacheEntrySerializer; +import org.apache.hc.client5.http.cache.HttpCacheStorageEntry; +import org.apache.hc.client5.http.cache.ResourceIOException; +import org.apache.hc.client5.http.impl.cache.AbstractSerializingCacheStorage; +import org.apache.hc.client5.http.impl.cache.CacheConfig; +import org.apache.hc.client5.http.impl.cache.HttpByteArrayCacheEntrySerializer; +import org.apache.hc.client5.http.impl.cache.NoopCacheEntrySerializer; +import org.apache.hc.core5.util.Args; + + +/** + *

This class is a storage backend for cache entries that uses the + * Caffeine + * cache implementation.

+ * + *

The size limits, eviction policy, and expiry policy are configured + * on the underlying Caffeine cache. The setting for + * {@link CacheConfig#getMaxCacheEntries()} is effectively ignored and + * should be enforced via the Caffeine configuration instead.

+ * + *

Please refer to the Caffeine documentation for details on how to + * configure the cache itself.

+ * + * @since 5.6 + */ +public class CaffeineHttpCacheStorage extends AbstractSerializingCacheStorage { + + /** + * Creates cache that stores {@link HttpCacheStorageEntry}s without direct serialization. + * + * @since 5.6 + */ + public static CaffeineHttpCacheStorage createObjectCache( + final Cache cache, final CacheConfig config) { + return new CaffeineHttpCacheStorage<>(cache, config, NoopCacheEntrySerializer.INSTANCE); + } + + /** + * Creates cache that stores serialized {@link HttpCacheStorageEntry}s. + * + * @since 5.6 + */ + public static CaffeineHttpCacheStorage createSerializedCache( + final Cache cache, final CacheConfig config) { + return new CaffeineHttpCacheStorage<>(cache, config, HttpByteArrayCacheEntrySerializer.INSTANCE); + } + + private final Cache cache; + + /** + * Constructs a storage backend using the provided Caffeine cache + * with the given configuration options, but using an alternative + * cache entry serialization strategy. + * + * @param cache where to store cached origin responses + * @param config cache storage configuration options - note that + * the setting for max object size and max entries + * should be configured on the Caffeine cache instead. + * @param serializer alternative serialization mechanism + */ + public CaffeineHttpCacheStorage( + final Cache cache, + final CacheConfig config, + final HttpCacheEntrySerializer serializer) { + super((config != null ? config : CacheConfig.DEFAULT).getMaxUpdateRetries(), + Args.notNull(serializer, "Cache entry serializer")); + this.cache = Args.notNull(cache, "Caffeine cache"); + } + + @Override + protected String digestToStorageKey(final String key) { + return key; + } + + @Override + protected void store(final String storageKey, final T storageObject) throws ResourceIOException { + cache.put(storageKey, storageObject); + } + + @Override + protected T restore(final String storageKey) throws ResourceIOException { + return cache.getIfPresent(storageKey); + } + + @Override + protected T getForUpdateCAS(final String storageKey) throws ResourceIOException { + return cache.getIfPresent(storageKey); + } + + @Override + protected T getStorageObject(final T element) throws ResourceIOException { + return element; + } + + @Override + protected boolean updateCAS( + final String storageKey, final T oldStorageObject, final T storageObject) throws ResourceIOException { + return cache.asMap().replace(storageKey, oldStorageObject, storageObject); + } + + @Override + protected void delete(final String storageKey) throws ResourceIOException { + cache.invalidate(storageKey); + } + + @Override + protected Map bulkRestore(final Collection storageKeys) throws ResourceIOException { + final Map present = cache.getAllPresent(storageKeys); + return new HashMap<>(present); + } + +} diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/caffeine/TestCaffeineHttpCacheStorage.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/caffeine/TestCaffeineHttpCacheStorage.java new file mode 100644 index 0000000000..5522264223 --- /dev/null +++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/caffeine/TestCaffeineHttpCacheStorage.java @@ -0,0 +1,187 @@ +/* + * ==================================================================== + * 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.impl.cache.caffeine; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Map; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import org.apache.hc.client5.http.cache.HttpCacheCASOperation; +import org.apache.hc.client5.http.cache.HttpCacheEntry; +import org.apache.hc.client5.http.cache.HttpCacheStorageEntry; +import org.apache.hc.client5.http.cache.Resource; +import org.apache.hc.client5.http.cache.ResourceIOException; +import org.apache.hc.client5.http.impl.cache.CacheConfig; +import org.apache.hc.client5.http.impl.cache.HeapResource; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.message.HeaderGroup; +import org.junit.jupiter.api.Test; + +class TestCaffeineHttpCacheStorage { + + private static HttpCacheEntry newEntry(final int status) throws ResourceIOException { + final Instant now = Instant.now(); + final Header[] responseHeaders = new Header[0]; + final Resource resource = new HeapResource(new byte[]{1, 2, 3}); + + final HeaderGroup requestHeaderGroup = new HeaderGroup(); + final HeaderGroup responseHeaderGroup = new HeaderGroup(); + responseHeaderGroup.setHeaders(responseHeaders); + + // Use the non-deprecated @Internal constructor + return new HttpCacheEntry( + now, + now, + "GET", + "/", + requestHeaderGroup, + status, + responseHeaderGroup, + resource, + null); + } + + private static CacheConfig newConfig() { + return CacheConfig.custom() + .setMaxUpdateRetries(3) + .build(); + } + + @Test + void testPutGetRemoveObjectCache() throws Exception { + final Cache cache = Caffeine.newBuilder().build(); + final CacheConfig config = newConfig(); + final CaffeineHttpCacheStorage storage = + CaffeineHttpCacheStorage.createObjectCache(cache, config); + + final String key = "foo"; + final HttpCacheEntry entry = newEntry(HttpStatus.SC_OK); + + storage.putEntry(key, entry); + + final HttpCacheEntry result = storage.getEntry(key); + assertNotNull(result); + assertEquals(HttpStatus.SC_OK, result.getStatus()); + + storage.removeEntry(key); + assertNull(storage.getEntry(key)); + } + + @Test + void testUpdateEntryObjectCache() throws Exception { + final Cache cache = Caffeine.newBuilder().build(); + final CacheConfig config = newConfig(); + final CaffeineHttpCacheStorage storage = + CaffeineHttpCacheStorage.createObjectCache(cache, config); + + final String key = "bar"; + final HttpCacheEntry original = newEntry(HttpStatus.SC_OK); + storage.putEntry(key, original); + + final HttpCacheCASOperation casOperation = existing -> { + assertNotNull(existing); + + final HeaderGroup requestHeaderGroup = new HeaderGroup(); + requestHeaderGroup.setHeaders(existing.requestHeaders().getHeaders()); + + final HeaderGroup responseHeaderGroup = new HeaderGroup(); + responseHeaderGroup.setHeaders(existing.responseHeaders().getHeaders()); + + return new HttpCacheEntry( + existing.getRequestInstant(), + existing.getResponseInstant(), + existing.getRequestMethod(), + existing.getRequestURI(), + requestHeaderGroup, + HttpStatus.SC_NOT_MODIFIED, + responseHeaderGroup, + existing.getResource(), + existing.getVariants()); + }; + + storage.updateEntry(key, casOperation); + + final HttpCacheEntry updated = storage.getEntry(key); + assertNotNull(updated); + assertEquals(HttpStatus.SC_NOT_MODIFIED, updated.getStatus()); + } + + @Test + void testGetEntriesUsesBulkRestore() throws Exception { + final Cache cache = Caffeine.newBuilder().build(); + final CacheConfig config = newConfig(); + final CaffeineHttpCacheStorage storage = + CaffeineHttpCacheStorage.createObjectCache(cache, config); + + final HttpCacheEntry entry1 = newEntry(HttpStatus.SC_OK); + final HttpCacheEntry entry2 = newEntry(HttpStatus.SC_CREATED); + + storage.putEntry("k1", entry1); + storage.putEntry("k2", entry2); + + final Map result = + storage.getEntries(Arrays.asList("k1", "k2", "k3")); + + assertEquals(2, result.size()); + assertEquals(HttpStatus.SC_OK, result.get("k1").getStatus()); + assertEquals(HttpStatus.SC_CREATED, result.get("k2").getStatus()); + assertFalse(result.containsKey("k3")); + } + + @Test + void testSerializedCacheStoresBytes() throws Exception { + final Cache cache = Caffeine.newBuilder().build(); + final CacheConfig config = newConfig(); + final CaffeineHttpCacheStorage storage = + CaffeineHttpCacheStorage.createSerializedCache(cache, config); + + final String key = "baz"; + final HttpCacheEntry entry = newEntry(HttpStatus.SC_OK); + + storage.putEntry(key, entry); + + // Underlying cache should contain serialized bytes + final byte[] stored = cache.getIfPresent(key); + assertNotNull(stored); + assertTrue(stored.length > 0); + + final HttpCacheEntry result = storage.getEntry(key); + assertNotNull(result); + assertEquals(HttpStatus.SC_OK, result.getStatus()); + } + +} diff --git a/pom.xml b/pom.xml index 4d5bc79145..809d7b734e 100644 --- a/pom.xml +++ b/pom.xml @@ -84,6 +84,7 @@ 1.5.2 1.52.0 1.26.2 + 2.9.3 @@ -257,6 +258,11 @@ ${brotli4j.version} true + + com.github.ben-manes.caffeine + caffeine + ${caffeine.version} +