Revisit Infinispan session idle and lifetime settings
Some checks are pending
Weblate Sync / Trigger Weblate to pull the latest changes (push) Waiting to run

Closes #46421

Signed-off-by: Pedro Ruivo <1492066+pruivo@users.noreply.github.com>
Co-authored-by: Pedro Ruivo <1492066+pruivo@users.noreply.github.com>
This commit is contained in:
Pedro Ruivo 2026-02-18 13:38:23 +00:00 committed by GitHub
parent 974eff0a92
commit 5096806b52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 119 additions and 28 deletions

View file

@ -56,6 +56,7 @@ import org.keycloak.models.sessions.infinispan.changes.PersistentSessionUpdateTa
import org.keycloak.models.sessions.infinispan.changes.SessionEntityWrapper;
import org.keycloak.models.sessions.infinispan.changes.SessionUpdateTask;
import org.keycloak.models.sessions.infinispan.changes.Tasks;
import org.keycloak.models.sessions.infinispan.changes.UserSessionInfinispanChangelogBasedTransaction;
import org.keycloak.models.sessions.infinispan.changes.sessions.PersisterLastSessionRefreshStore;
import org.keycloak.models.sessions.infinispan.entities.AuthenticatedClientSessionEntity;
import org.keycloak.models.sessions.infinispan.entities.EmbeddedClientSessionKey;
@ -96,10 +97,10 @@ public class InfinispanUserSessionProvider implements UserSessionProvider, Sessi
protected final KeycloakSession session;
protected final InfinispanChangelogBasedTransaction<String, UserSessionEntity> sessionTx;
protected final InfinispanChangelogBasedTransaction<String, UserSessionEntity> offlineSessionTx;
protected final InfinispanChangelogBasedTransaction<EmbeddedClientSessionKey, AuthenticatedClientSessionEntity> clientSessionTx;
protected final InfinispanChangelogBasedTransaction<EmbeddedClientSessionKey, AuthenticatedClientSessionEntity> offlineClientSessionTx;
protected final UserSessionInfinispanChangelogBasedTransaction<String, UserSessionEntity> sessionTx;
protected final UserSessionInfinispanChangelogBasedTransaction<String, UserSessionEntity> offlineSessionTx;
protected final UserSessionInfinispanChangelogBasedTransaction<EmbeddedClientSessionKey, AuthenticatedClientSessionEntity> clientSessionTx;
protected final UserSessionInfinispanChangelogBasedTransaction<EmbeddedClientSessionKey, AuthenticatedClientSessionEntity> offlineClientSessionTx;
protected final SessionEventsSenderTransaction clusterEventsSenderTx;
@ -111,10 +112,10 @@ public class InfinispanUserSessionProvider implements UserSessionProvider, Sessi
public InfinispanUserSessionProvider(KeycloakSession session,
PersisterLastSessionRefreshStore persisterLastSessionRefreshStore,
InfinispanChangelogBasedTransaction<String, UserSessionEntity> sessionTx,
InfinispanChangelogBasedTransaction<String, UserSessionEntity> offlineSessionTx,
InfinispanChangelogBasedTransaction<EmbeddedClientSessionKey, AuthenticatedClientSessionEntity> clientSessionTx,
InfinispanChangelogBasedTransaction<EmbeddedClientSessionKey, AuthenticatedClientSessionEntity> offlineClientSessionTx,
UserSessionInfinispanChangelogBasedTransaction<String, UserSessionEntity> sessionTx,
UserSessionInfinispanChangelogBasedTransaction<String, UserSessionEntity> offlineSessionTx,
UserSessionInfinispanChangelogBasedTransaction<EmbeddedClientSessionKey, AuthenticatedClientSessionEntity> clientSessionTx,
UserSessionInfinispanChangelogBasedTransaction<EmbeddedClientSessionKey, AuthenticatedClientSessionEntity> offlineClientSessionTx,
SessionFunction<UserSessionEntity> offlineSessionCacheEntryLifespanAdjuster,
SessionFunction<AuthenticatedClientSessionEntity> offlineClientSessionCacheEntryLifespanAdjuster) {
this.session = session;

View file

@ -40,10 +40,10 @@ import org.keycloak.models.UserSessionProviderFactory;
import org.keycloak.models.UserSessionSpi;
import org.keycloak.models.sessions.infinispan.changes.CacheHolder;
import org.keycloak.models.sessions.infinispan.changes.ClientSessionPersistentChangelogBasedTransaction;
import org.keycloak.models.sessions.infinispan.changes.InfinispanChangelogBasedTransaction;
import org.keycloak.models.sessions.infinispan.changes.InfinispanChangesUtils;
import org.keycloak.models.sessions.infinispan.changes.PersistentSessionsWorker;
import org.keycloak.models.sessions.infinispan.changes.PersistentUpdate;
import org.keycloak.models.sessions.infinispan.changes.UserSessionInfinispanChangelogBasedTransaction;
import org.keycloak.models.sessions.infinispan.changes.UserSessionPersistentChangelogBasedTransaction;
import org.keycloak.models.sessions.infinispan.changes.sessions.PersisterLastSessionRefreshStore;
import org.keycloak.models.sessions.infinispan.changes.sessions.PersisterLastSessionRefreshStoreFactory;
@ -422,10 +422,10 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider
}
private VolatileTransactions createVolatileTransaction(KeycloakSession session) {
var sessionTx = new InfinispanChangelogBasedTransaction<>(session, sessionCacheHolder);
var offlineSessionTx = new InfinispanChangelogBasedTransaction<>(session, offlineSessionCacheHolder);
var clientSessionTx = new InfinispanChangelogBasedTransaction<>(session, clientSessionCacheHolder);
var offlineClientSessionTx = new InfinispanChangelogBasedTransaction<>(session, offlineClientSessionCacheHolder);
var sessionTx = new UserSessionInfinispanChangelogBasedTransaction<>(session, sessionCacheHolder);
var offlineSessionTx = new UserSessionInfinispanChangelogBasedTransaction<>(session, offlineSessionCacheHolder);
var clientSessionTx = new UserSessionInfinispanChangelogBasedTransaction<>(session, clientSessionCacheHolder);
var offlineClientSessionTx = new UserSessionInfinispanChangelogBasedTransaction<>(session, offlineClientSessionCacheHolder);
var transactionProvider = session.getProvider(InfinispanTransactionProvider.class);
transactionProvider.registerTransaction(sessionTx);
@ -453,10 +453,11 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider
return new PersistentTransaction(sessionTx, clientSessionTx);
}
private record VolatileTransactions(InfinispanChangelogBasedTransaction<String, UserSessionEntity> sessionTx,
InfinispanChangelogBasedTransaction<String, UserSessionEntity> offlineSessionTx,
InfinispanChangelogBasedTransaction<EmbeddedClientSessionKey, AuthenticatedClientSessionEntity> clientSessionTx,
InfinispanChangelogBasedTransaction<EmbeddedClientSessionKey, AuthenticatedClientSessionEntity> offlineClientSessionTx) {}
private record VolatileTransactions(
UserSessionInfinispanChangelogBasedTransaction<String, UserSessionEntity> sessionTx,
UserSessionInfinispanChangelogBasedTransaction<String, UserSessionEntity> offlineSessionTx,
UserSessionInfinispanChangelogBasedTransaction<EmbeddedClientSessionKey, AuthenticatedClientSessionEntity> clientSessionTx,
UserSessionInfinispanChangelogBasedTransaction<EmbeddedClientSessionKey, AuthenticatedClientSessionEntity> offlineClientSessionTx) {}
private record PersistentTransaction(UserSessionPersistentChangelogBasedTransaction userTx, ClientSessionPersistentChangelogBasedTransaction clientTx) {}

View file

@ -167,7 +167,7 @@ public class InfinispanChangelogBasedTransaction<K, V extends SessionEntity> imp
long lifespanMs = cacheHolder.lifespanFunction().apply(realm, sessionUpdates.getClient(), sessionWrapper.getEntity());
long maxIdleTimeMs = cacheHolder.maxIdleFunction().apply(realm, sessionUpdates.getClient(), sessionWrapper.getEntity());
MergedUpdate<V> merged = MergedUpdate.computeUpdate(updateTasks, sessionWrapper, lifespanMs, maxIdleTimeMs);
MergedUpdate<V> merged = MergedUpdate.computeUpdate(updateTasks, sessionWrapper, computeLifespan(maxIdleTimeMs, lifespanMs), computeMaxIdle(maxIdleTimeMs, lifespanMs));
if (merged != null) {
// Now run the operation in our cluster
@ -216,7 +216,7 @@ public class InfinispanChangelogBasedTransaction<K, V extends SessionEntity> imp
// exists in transaction, avoid cache operation
return updatesList.getEntityWrapper().getEntity();
}
SessionEntityWrapper<V> existing = cacheHolder.cache().putIfAbsent(key, session, lifespan, TimeUnit.MILLISECONDS, maxIdle, TimeUnit.MILLISECONDS);
SessionEntityWrapper<V> existing = cacheHolder.cache().putIfAbsent(key, session, computeLifespan(maxIdle, lifespan), TimeUnit.MILLISECONDS, computeMaxIdle(maxIdle, lifespan), TimeUnit.MILLISECONDS);
if (existing == null) {
// keep track of the imported session for updates
updates.put(key, new SessionUpdatesList<>(realmModel, session));
@ -263,7 +263,7 @@ public class InfinispanChangelogBasedTransaction<K, V extends SessionEntity> imp
//nothing to import, already expired
return;
}
var future = cacheHolder.cache().putIfAbsentAsync(key, session, lifespan, TimeUnit.MILLISECONDS, maxIdle, TimeUnit.MILLISECONDS);
var future = cacheHolder.cache().putIfAbsentAsync(key, session, computeLifespan(maxIdle, lifespan), TimeUnit.MILLISECONDS, computeMaxIdle(maxIdle, lifespan), TimeUnit.MILLISECONDS);
// write result into concurrent hash map because the consumer is invoked in a different thread each time.
stage.dependsOn(future.thenAccept(existing -> allSessions.put(key, existing == null ? session : existing)));
});
@ -288,4 +288,12 @@ public class InfinispanChangelogBasedTransaction<K, V extends SessionEntity> imp
// Run the update now, so reader in same transaction can see it (TODO: Rollback may not work correctly. See if it's an issue..)
myUpdates.addAndExecute(task);
}
protected long computeLifespan(long maxIdle, long lifespan) {
return lifespan;
}
protected long computeMaxIdle(long maxIdle, long lifespan) {
return maxIdle;
}
}

View file

@ -128,7 +128,7 @@ abstract public class PersistentSessionsChangelogBasedTransaction<K, V extends S
long lifespanMs = getLifespanMsLoader(isOffline).apply(realm, sessionUpdates.getClient(), entity);
long maxIdleTimeMs = getMaxIdleMsLoader(isOffline).apply(realm, sessionUpdates.getClient(), entity);
MergedUpdate<V> merged = MergedUpdate.computeUpdate(sessionUpdates.getUpdateTasks(), sessionWrapper, lifespanMs, maxIdleTimeMs);
MergedUpdate<V> merged = MergedUpdate.computeUpdate(sessionUpdates.getUpdateTasks(), sessionWrapper, SessionTimeouts.calculateEffectiveSessionLifespan(maxIdleTimeMs, lifespanMs), SessionTimeouts.IMMORTAL_FLAG);
if (merged != null) {
var c = isOffline ? offlineCacheHolder : cacheHolder;
@ -274,7 +274,7 @@ abstract public class PersistentSessionsChangelogBasedTransaction<K, V extends S
SessionEntityWrapper<V> existing = null;
try {
if (getCache(offline) != null) {
existing = getCache(offline).putIfAbsent(key, session, lifespan, TimeUnit.MILLISECONDS, maxIdle, TimeUnit.MILLISECONDS);
existing = getCache(offline).putIfAbsent(key, session, SessionTimeouts.calculateEffectiveSessionLifespan(maxIdle, lifespan), TimeUnit.MILLISECONDS);
}
} catch (RuntimeException exception) {
// If the import fails, the transaction can continue with the data from the database.
@ -324,7 +324,7 @@ abstract public class PersistentSessionsChangelogBasedTransaction<K, V extends S
//nothing to import, already expired
return;
}
var future = cache.putIfAbsentAsync(key, session, lifespan, TimeUnit.MILLISECONDS, maxIdle, TimeUnit.MILLISECONDS)
var future = cache.putIfAbsentAsync(key, session, SessionTimeouts.calculateEffectiveSessionLifespan(maxIdle, lifespan), TimeUnit.MILLISECONDS)
.exceptionally(throwable -> {
// If the import fails, the transaction can continue with the data from the database.
LOG.debugf(throwable, "Failed to import session %s", session);

View file

@ -0,0 +1,45 @@
/*
* Copyright 2026 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed 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.
*/
package org.keycloak.models.sessions.infinispan.changes;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.sessions.infinispan.entities.SessionEntity;
import org.keycloak.models.sessions.infinispan.util.SessionTimeouts;
/**
* User session transaction implementation that optimizes Infinispan cache expiration settings.
* <p>
* This class overrides the parent's timeout computation to disable max-idle tracking, which is expensive for Infinispan
* to maintain. Instead, the lifespan is adjusted to be the minimum of the original lifespan and max-idle values,
* ensuring sessions still expire at the correct time without the overhead of idle time tracking.
*/
public class UserSessionInfinispanChangelogBasedTransaction<K, V extends SessionEntity> extends InfinispanChangelogBasedTransaction<K, V> {
public UserSessionInfinispanChangelogBasedTransaction(KeycloakSession kcSession, CacheHolder<K, V> cacheHolder) {
super(kcSession, cacheHolder);
}
@Override
protected long computeLifespan(long maxIdle, long lifespan) {
return SessionTimeouts.calculateEffectiveSessionLifespan(maxIdle, lifespan);
}
@Override
protected long computeMaxIdle(long maxIdle, long lifespan) {
return SessionTimeouts.IMMORTAL_FLAG;
}
}

View file

@ -104,7 +104,7 @@ public class AuthenticatedClientSessionUpdater extends BaseUpdater<ClientSession
public Expiration computeExpiration() {
long maxIdle = SessionTimeouts.getClientSessionMaxIdleMs(userSession.getRealm(), client, offline, isUserSessionRememberMe(), getTimestamp());
long lifespan = SessionTimeouts.getClientSessionLifespanMs(userSession.getRealm(), client, offline, isUserSessionRememberMe(), getStarted(), getUserSessionStarted());
return new Expiration(maxIdle, lifespan);
return new Expiration(SessionTimeouts.IMMORTAL_FLAG, SessionTimeouts.calculateEffectiveSessionLifespan(maxIdle, lifespan));
}
@Override

View file

@ -79,7 +79,7 @@ public class UserSessionUpdater extends BaseUpdater<String, RemoteUserSessionEnt
public Expiration computeExpiration() {
long maxIdle = SessionTimeouts.getUserSessionMaxIdleMs(realm, isOffline(), getValue().isRememberMe(), getValue().getLastSessionRefresh());
long lifespan = SessionTimeouts.getUserSessionLifespanMs(realm, isOffline(), getValue().isRememberMe(), getValue().getStarted());
return new Expiration(maxIdle, lifespan);
return new Expiration(SessionTimeouts.IMMORTAL_FLAG, SessionTimeouts.calculateEffectiveSessionLifespan(maxIdle, lifespan));
}
@Override

View file

@ -40,7 +40,7 @@ public class SessionTimeouts {
*/
public static final long ENTRY_EXPIRED_FLAG = -2;
private static final long IMMORTAL_FLAG = -1;
public static final long IMMORTAL_FLAG = -1;
/**
* Get the maximum lifespan, which this userSession can remain in the infinispan cache.
@ -244,4 +244,32 @@ public class SessionTimeouts {
public static long getAuthSessionMaxIdleMS(RealmModel realm, ClientModel client, RootAuthenticationSessionEntity entity) {
return IMMORTAL_FLAG;
}
/**
* Calculates the effective lifespan value to use when storing user and client session entries in the Infinispan
* cache.
* <p>
* This method optimizes Infinispan cache configuration by incorporating the max-idle timeout into the lifespan
* value. Since Infinispan's max-idle implementation is expensive (requires tracking last access time and additional
* overhead), this optimization avoids using max-idle directly and instead sets the lifespan to the minimum of
* max-idle and lifespan values. This ensures session entries expire at the correct time without the performance
* cost of max-idle tracking.
*
* @param maxIdle the maximum idle time in milliseconds, or {@link #IMMORTAL_FLAG} for no idle timeout
* @param lifespan the maximum lifespan in milliseconds, or {@link #IMMORTAL_FLAG} for no lifespan timeout
* @return the effective lifespan to use for the session cache entry: returns {@code lifespan} if {@code maxIdle} is
* {@link #IMMORTAL_FLAG}, {@code maxIdle} if {@code lifespan} is {@link #IMMORTAL_FLAG}, otherwise returns
* {@code min(maxIdle, lifespan)}
*/
public static long calculateEffectiveSessionLifespan(long maxIdle, long lifespan) {
// currently, max-idle is never IMMORTAL_FLAG; the jvm should be able to remove the check.
// keep it to be future-proof.
if (maxIdle == IMMORTAL_FLAG) {
return lifespan;
}
if (lifespan == IMMORTAL_FLAG) {
return maxIdle;
}
return Math.min(maxIdle, lifespan);
}
}

View file

@ -53,6 +53,10 @@ import org.keycloak.authorization.DefaultAuthorizationProviderFactory;
import org.keycloak.authorization.policy.provider.PolicyProviderFactory;
import org.keycloak.authorization.policy.provider.PolicySpi;
import org.keycloak.authorization.store.StoreFactorySpi;
import org.keycloak.cache.AlternativeLookupProviderFactory;
import org.keycloak.cache.AlternativeLookupSPI;
import org.keycloak.cache.LocalCacheProviderFactory;
import org.keycloak.cache.LocalCacheSPI;
import org.keycloak.cluster.ClusterSpi;
import org.keycloak.common.Profile;
import org.keycloak.common.profile.PropertiesProfileConfigResolver;
@ -254,7 +258,9 @@ public abstract class KeycloakModelTest {
UserSessionSpi.class,
UserSpi.class,
DatastoreSpi.class,
CacheRemoteConfigProviderSpi.class);
CacheRemoteConfigProviderSpi.class,
AlternativeLookupSPI.class,
LocalCacheSPI.class);
private static final Set<Class<? extends ProviderFactory>> ALLOWED_FACTORIES = Set.of(
ComponentFactoryProviderFactory.class,
@ -264,7 +270,9 @@ public abstract class KeycloakModelTest {
DeploymentStateProviderFactory.class,
DatastoreProviderFactory.class,
TracingProviderFactory.class,
CacheRemoteConfigProviderFactory.class);
CacheRemoteConfigProviderFactory.class,
AlternativeLookupProviderFactory.class,
LocalCacheProviderFactory.class);
protected static final List<KeycloakModelParameters> MODEL_PARAMETERS;
protected static final Config CONFIG = new Config(KeycloakModelTest::useDefaultFactory);