diff --git a/server-spi-private/src/main/java/org/keycloak/cache/AlternativeLookupProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/cache/AlternativeLookupProviderFactory.java index 2aef5bae34b..467969e30b8 100644 --- a/server-spi-private/src/main/java/org/keycloak/cache/AlternativeLookupProviderFactory.java +++ b/server-spi-private/src/main/java/org/keycloak/cache/AlternativeLookupProviderFactory.java @@ -1,6 +1,13 @@ package org.keycloak.cache; +import java.util.Set; + +import org.keycloak.provider.Provider; import org.keycloak.provider.ProviderFactory; public interface AlternativeLookupProviderFactory extends ProviderFactory { + @Override + default Set> dependsOn() { + return Set.of(LocalCacheProvider.class); + } } diff --git a/server-spi-private/src/main/java/org/keycloak/cache/LocalCache.java b/server-spi-private/src/main/java/org/keycloak/cache/LocalCache.java new file mode 100644 index 00000000000..ee1a8642223 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/cache/LocalCache.java @@ -0,0 +1,43 @@ +package org.keycloak.cache; + +/** + * A {@link LocalCache} should be used when a local, non-clustered, cache is required to optimise data access. + * + * @param the type of the cache Keys used for lookup + * @param the type of the cache Values to be stored + */ +public interface LocalCache extends AutoCloseable { + + /** + * Returns the value associated with the {@code key}, or {@code null} if there is no + * cached value for the {@code key}. + * + * @param key the key whose associated value is to be returned + * @return the value associated with the specified key or {@code null} if no value exists + * @throws NullPointerException if the specified key is null + */ + V get(K key); + + /** + * Associates the value with the key in this cache. + * If the cache previously contained a value associated with the key, the old value is replaced by the new value. + * + * @param key the key with which the specified value is to be associated + * @param value value to be associated with the specified key + * @throws NullPointerException if the specified key or value is null + */ + void put(K key, V value); + + /** + * Removes the cached value for the specified {@code key}. + * + * @param key the key whose mapping is to be removed from the cache + * @throws NullPointerException if the specified key is null + */ + void invalidate(K key); + + /** + * Closes all resources associated with the cache. + */ + void close(); +} diff --git a/server-spi-private/src/main/java/org/keycloak/cache/LocalCacheConfiguration.java b/server-spi-private/src/main/java/org/keycloak/cache/LocalCacheConfiguration.java new file mode 100644 index 00000000000..0497375ff59 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/cache/LocalCacheConfiguration.java @@ -0,0 +1,72 @@ +package org.keycloak.cache; + +import java.time.Duration; +import java.util.Objects; +import java.util.function.Function; + +/** + * Configuration {@code record} that encapsulates all configuration required in order to create a {@link LocalCache}. + * + * @param name the name of the cache + * @param maxSize the maximum size of the cache, or -1 if an unbounded cache is desired. + * @param expiration the {@link Duration} to wait before entries are expired. A {@code null} value indicates no expiration. + * @param loader an optional {@link Function} which is used to retrieve cache values on cache miss. + * @param the type of the cache Keys used for lookup + * @param the type of the cache Values to be stored + */ +public record LocalCacheConfiguration(String name, int maxSize, Duration expiration, Function loader) { + + public LocalCacheConfiguration { + Objects.requireNonNull(name, "A cache name must be configured"); + } + + public boolean hasExpiration() { + return expiration != null; + } + + public boolean hasLoader() { + return loader != null; + } + + public static Builder builder() { + return new Builder<>(); + } + + /** + * A builder class to simplify the creation of {@link LocalCacheConfiguration} objects. + * + * @param the type of the cache Keys used for lookup + * @param the type of the cache Values to be stored + */ + public static class Builder { + + private String name; + private int maxSize = -1; + private Duration expiration; + private Function loader; + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder maxSize(int maxSize) { + this.maxSize = maxSize; + return this; + } + + public Builder expiration(Duration duration) { + this.expiration = duration; + return this; + } + + public Builder loader(Function loader) { + this.loader = loader; + return this; + } + + public LocalCacheConfiguration build() { + return new LocalCacheConfiguration<>(name, maxSize, expiration, loader); + } + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/cache/LocalCacheProvider.java b/server-spi-private/src/main/java/org/keycloak/cache/LocalCacheProvider.java new file mode 100644 index 00000000000..16d4fbe56ed --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/cache/LocalCacheProvider.java @@ -0,0 +1,21 @@ +package org.keycloak.cache; + +import org.keycloak.provider.Provider; + +/** + * A {@link Provider} to abstract the creation of local, non-clustered, in-memory caches from the underlying cache implementation. + */ +public interface LocalCacheProvider extends Provider { + /** + * Creates a new {@link LocalCache} instance for local caching. {@link LocalCacheProvider} implementations + * are not responsible for managing the lifecycle of created {@link LocalCache} instances. It is the responsibility + * of {@link LocalCache} consumers to ensure that {@link LocalCache#close()} is called when the cache is no longer + * required. + * + * @param configuration the desired cache configuration + * @return {@link LocalCache} a newly created cache + * @param the type of the cache Keys used for lookup + * @param the type of the cache Values to be stored + */ + LocalCache create(LocalCacheConfiguration configuration); +} diff --git a/server-spi-private/src/main/java/org/keycloak/cache/LocalCacheProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/cache/LocalCacheProviderFactory.java new file mode 100644 index 00000000000..6f19e566d06 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/cache/LocalCacheProviderFactory.java @@ -0,0 +1,6 @@ +package org.keycloak.cache; + +import org.keycloak.provider.ProviderFactory; + +public interface LocalCacheProviderFactory extends ProviderFactory { +} diff --git a/server-spi-private/src/main/java/org/keycloak/cache/LocalCacheSPI.java b/server-spi-private/src/main/java/org/keycloak/cache/LocalCacheSPI.java new file mode 100644 index 00000000000..38c94a7b57d --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/cache/LocalCacheSPI.java @@ -0,0 +1,27 @@ +package org.keycloak.cache; + +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +public class LocalCacheSPI implements Spi { + @Override + public boolean isInternal() { + return true; + } + + @Override + public String getName() { + return "localCache"; + } + + @Override + public Class getProviderClass() { + return LocalCacheProvider.class; + } + + @Override + public Class> getProviderFactoryClass() { + return LocalCacheProviderFactory.class; + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/device/DeviceRepresentationProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/device/DeviceRepresentationProviderFactory.java index 94711dae7d0..9ea1d1e9898 100644 --- a/server-spi-private/src/main/java/org/keycloak/device/DeviceRepresentationProviderFactory.java +++ b/server-spi-private/src/main/java/org/keycloak/device/DeviceRepresentationProviderFactory.java @@ -1,7 +1,11 @@ package org.keycloak.device; +import java.util.Set; + import org.keycloak.Config; +import org.keycloak.cache.LocalCacheProvider; import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.Provider; import org.keycloak.provider.ProviderFactory; public interface DeviceRepresentationProviderFactory extends ProviderFactory { @@ -17,4 +21,9 @@ public interface DeviceRepresentationProviderFactory extends ProviderFactory> dependsOn() { + return Set.of(LocalCacheProvider.class); + } } diff --git a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi index fab418d826c..024973e1b81 100755 --- a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -110,4 +110,5 @@ org.keycloak.models.workflow.WorkflowSpi org.keycloak.models.workflow.WorkflowConditionSpi org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessorSpi org.keycloak.cache.AlternativeLookupSPI +org.keycloak.cache.LocalCacheSPI org.keycloak.models.workflow.WorkflowStateSpi \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/cache/DefaultAlternativeLookupProvider.java b/services/src/main/java/org/keycloak/cache/DefaultAlternativeLookupProvider.java index e12d2ad7740..73ca5622bbd 100644 --- a/services/src/main/java/org/keycloak/cache/DefaultAlternativeLookupProvider.java +++ b/services/src/main/java/org/keycloak/cache/DefaultAlternativeLookupProvider.java @@ -8,20 +8,18 @@ import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.IdentityProviderQuery; import org.keycloak.models.KeycloakSession; -import com.github.benmanes.caffeine.cache.Cache; - public class DefaultAlternativeLookupProvider implements AlternativeLookupProvider { - private final Cache lookupCache; + private final LocalCache lookupCache; - public DefaultAlternativeLookupProvider(Cache lookupCache) { + public DefaultAlternativeLookupProvider(LocalCache lookupCache) { this.lookupCache = lookupCache; } public IdentityProviderModel lookupIdentityProviderFromIssuer(KeycloakSession session, String issuerUrl) { String alternativeKey = ComputedKey.computeKey(session.getContext().getRealm().getId(), "idp", issuerUrl); - String cachedIdpAlias = lookupCache.getIfPresent(alternativeKey); + String cachedIdpAlias = lookupCache.get(alternativeKey); if (cachedIdpAlias != null) { IdentityProviderModel idp = session.identityProviders().getByAlias(cachedIdpAlias); if (idp != null && issuerUrl.equals(idp.getConfig().get(IdentityProviderModel.ISSUER))) { @@ -43,7 +41,7 @@ public class DefaultAlternativeLookupProvider implements AlternativeLookupProvid public ClientModel lookupClientFromClientAttributes(KeycloakSession session, Map attributes) { String alternativeKey = ComputedKey.computeKey(session.getContext().getRealm().getId(), "client", attributes); - String cachedClientId = lookupCache.getIfPresent(alternativeKey); + String cachedClientId = lookupCache.get(alternativeKey); if (cachedClientId != null) { ClientModel client = session.clients().getClientByClientId(session.getContext().getRealm(), cachedClientId); boolean match = client != null; diff --git a/services/src/main/java/org/keycloak/cache/DefaultAlternativeLookupProviderFactory.java b/services/src/main/java/org/keycloak/cache/DefaultAlternativeLookupProviderFactory.java index d2e989427e9..7dcfc4527d7 100644 --- a/services/src/main/java/org/keycloak/cache/DefaultAlternativeLookupProviderFactory.java +++ b/services/src/main/java/org/keycloak/cache/DefaultAlternativeLookupProviderFactory.java @@ -1,19 +1,15 @@ package org.keycloak.cache; -import java.util.concurrent.TimeUnit; +import java.time.Duration; import org.keycloak.Config; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.binder.cache.CaffeineStatsCounter; - public class DefaultAlternativeLookupProviderFactory implements AlternativeLookupProviderFactory { - private Cache lookupCache; + private LocalCacheConfiguration cacheConfig; + private LocalCache lookupCache; @Override public String getId() { @@ -30,25 +26,26 @@ public class DefaultAlternativeLookupProviderFactory implements AlternativeLooku Integer maximumSize = config.getInt("maximumSize", 1000); Integer expireAfter = config.getInt("expireAfter", 60); - CaffeineStatsCounter metrics = new CaffeineStatsCounter(Metrics.globalRegistry, "lookup"); - - this.lookupCache = Caffeine.newBuilder() - .maximumSize(maximumSize) - .expireAfterAccess(expireAfter, TimeUnit.MINUTES) - .recordStats(() -> metrics) - .build(); - - metrics.registerSizeMetric(lookupCache); + cacheConfig = LocalCacheConfiguration.builder() + .name("lookup") + .expiration(Duration.ofMinutes(expireAfter)) + .maxSize(maximumSize) + .build(); } @Override public void postInit(KeycloakSessionFactory factory) { + try (KeycloakSession session = factory.create()) { + lookupCache = session.getProvider(LocalCacheProvider.class).create(cacheConfig); + cacheConfig = null; + } } @Override public void close() { - lookupCache.cleanUp(); - lookupCache = null; + if (lookupCache != null) { + lookupCache.close(); + lookupCache = null; + } } - } diff --git a/services/src/main/java/org/keycloak/cache/DefaultLocalCacheProviderFactory.java b/services/src/main/java/org/keycloak/cache/DefaultLocalCacheProviderFactory.java new file mode 100644 index 00000000000..86833c8fd5b --- /dev/null +++ b/services/src/main/java/org/keycloak/cache/DefaultLocalCacheProviderFactory.java @@ -0,0 +1,114 @@ +package org.keycloak.cache; + +import java.util.Objects; + +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import io.micrometer.core.instrument.Metrics; +import io.micrometer.core.instrument.binder.cache.CaffeineStatsCounter; + +/** + * The default implementation for {@link LocalCacheProvider} and {@link LocalCacheProviderFactory}. + */ +public class DefaultLocalCacheProviderFactory implements LocalCacheProvider, LocalCacheProviderFactory { + + @Override + public LocalCacheProvider create(KeycloakSession session) { + return this; + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public String getId() { + return "default"; + } + + @Override + public LocalCache create(LocalCacheConfiguration configuration) { + CaffeineStatsCounter metrics = new CaffeineStatsCounter(Metrics.globalRegistry, configuration.name()); + Caffeine builder = Caffeine.newBuilder().recordStats(() -> metrics); + + if (configuration.maxSize() > 0) { + builder.maximumSize(configuration.maxSize()); + } + + if (configuration.hasExpiration()) { + builder.expireAfterAccess(configuration.expiration()); + } + + if (configuration.hasLoader()) { + LoadingCache cache = builder.build(k -> configuration.loader().apply(k)); + metrics.registerSizeMetric(cache); + return new LoadingCaffeineWrapper<>(cache); + } else { + Cache cache = builder.build(); + metrics.registerSizeMetric(cache); + return new CaffeineWrapper<>(cache); + } + } + + @Override + public void close() { + } + + private static class CaffeineWrapper implements LocalCache { + + final Cache cache; + + CaffeineWrapper(Cache cache) { + this.cache = cache; + } + + @Override + public V get(K key) { + Objects.requireNonNull(key); + return cache.getIfPresent(key); + } + + @Override + public void put(K key, V value) { + Objects.requireNonNull(key); + Objects.requireNonNull(value); + cache.put(key, value); + } + + @Override + public void invalidate(K key) { + Objects.requireNonNull(key); + cache.invalidate(key); + } + + @Override + public void close() { + cache.cleanUp(); + } + } + + private static class LoadingCaffeineWrapper extends CaffeineWrapper { + + final LoadingCache cache; + + LoadingCaffeineWrapper(LoadingCache cache) { + super(cache); + this.cache = cache; + } + + @Override + public V get(K key) { + Objects.requireNonNull(key); + return cache.get(key); + } + } +} diff --git a/services/src/main/java/org/keycloak/device/DeviceRepresentationProviderFactoryImpl.java b/services/src/main/java/org/keycloak/device/DeviceRepresentationProviderFactoryImpl.java index 5a2323e377d..d71b2d95443 100644 --- a/services/src/main/java/org/keycloak/device/DeviceRepresentationProviderFactoryImpl.java +++ b/services/src/main/java/org/keycloak/device/DeviceRepresentationProviderFactoryImpl.java @@ -3,14 +3,14 @@ package org.keycloak.device; import java.util.List; import org.keycloak.Config; +import org.keycloak.cache.LocalCache; +import org.keycloak.cache.LocalCacheConfiguration; +import org.keycloak.cache.LocalCacheProvider; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigurationBuilder; -import com.github.benmanes.caffeine.cache.Caffeine; -import com.github.benmanes.caffeine.cache.LoadingCache; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.binder.cache.CaffeineStatsCounter; import ua_parser.Client; import ua_parser.Parser; @@ -21,11 +21,11 @@ public class DeviceRepresentationProviderFactoryImpl implements DeviceRepresenta // The max user agent size is 512 bytes and it will take 1024 bytes per cache entry. // Using 2MB for caching. private static final int DEFAULT_CACHE_SIZE = 2048; - - private volatile LoadingCache cache; - public static final String PROVIDER_ID = "deviceRepresentation"; + private LocalCacheConfiguration cacheConfig; + private LocalCache cache; + @Override public String getId() { return PROVIDER_ID; @@ -33,13 +33,19 @@ public class DeviceRepresentationProviderFactoryImpl implements DeviceRepresenta @Override public void init(Config.Scope config) { - CaffeineStatsCounter metrics = new CaffeineStatsCounter(Metrics.globalRegistry, "userAgent"); - cache = Caffeine.newBuilder() - .maximumSize(config.getInt(CACHE_SIZE, DEFAULT_CACHE_SIZE)) - .recordStats(() -> metrics) - .softValues() - .build(UA_PARSER::parse); - metrics.registerSizeMetric(cache); + cacheConfig = LocalCacheConfiguration.builder() + .name("userAgent") + .maxSize(config.getInt(CACHE_SIZE, DEFAULT_CACHE_SIZE)) + .loader(UA_PARSER::parse) + .build(); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + try (KeycloakSession session = factory.create()) { + cache = session.getProvider(LocalCacheProvider.class).create(cacheConfig); + cacheConfig = null; + } } @Override @@ -58,4 +64,12 @@ public class DeviceRepresentationProviderFactoryImpl implements DeviceRepresenta .add() .build(); } + + @Override + public void close() { + if (cache != null) { + cache.close(); + cache = null; + } + } } diff --git a/services/src/main/java/org/keycloak/device/DeviceRepresentationProviderImpl.java b/services/src/main/java/org/keycloak/device/DeviceRepresentationProviderImpl.java index 3a29fc99a54..2d650d15e5f 100644 --- a/services/src/main/java/org/keycloak/device/DeviceRepresentationProviderImpl.java +++ b/services/src/main/java/org/keycloak/device/DeviceRepresentationProviderImpl.java @@ -2,11 +2,11 @@ package org.keycloak.device; import jakarta.ws.rs.core.HttpHeaders; +import org.keycloak.cache.LocalCache; import org.keycloak.models.KeycloakContext; import org.keycloak.models.KeycloakSession; import org.keycloak.representations.account.DeviceRepresentation; -import com.github.benmanes.caffeine.cache.LoadingCache; import org.jboss.logging.Logger; import ua_parser.Client; @@ -14,10 +14,10 @@ public class DeviceRepresentationProviderImpl implements DeviceRepresentationPro private static final Logger logger = Logger.getLogger(DeviceActivityManager.class); private static final int USER_AGENT_MAX_LENGTH = 512; - private final LoadingCache cache; + private final LocalCache cache; private final KeycloakSession session; - DeviceRepresentationProviderImpl(KeycloakSession session, LoadingCache cache) { + DeviceRepresentationProviderImpl(KeycloakSession session, LocalCache cache) { this.session = session; this.cache = cache; } diff --git a/services/src/main/resources/META-INF/services/org.keycloak.cache.LocalCacheProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.cache.LocalCacheProviderFactory new file mode 100644 index 00000000000..91f394d0410 --- /dev/null +++ b/services/src/main/resources/META-INF/services/org.keycloak.cache.LocalCacheProviderFactory @@ -0,0 +1 @@ +org.keycloak.cache.DefaultLocalCacheProviderFactory \ No newline at end of file