diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java index 2c83f291fb9..5004a251e1a 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java @@ -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 sessionTx; - protected final InfinispanChangelogBasedTransaction offlineSessionTx; - protected final InfinispanChangelogBasedTransaction clientSessionTx; - protected final InfinispanChangelogBasedTransaction offlineClientSessionTx; + protected final UserSessionInfinispanChangelogBasedTransaction sessionTx; + protected final UserSessionInfinispanChangelogBasedTransaction offlineSessionTx; + protected final UserSessionInfinispanChangelogBasedTransaction clientSessionTx; + protected final UserSessionInfinispanChangelogBasedTransaction offlineClientSessionTx; protected final SessionEventsSenderTransaction clusterEventsSenderTx; @@ -111,10 +112,10 @@ public class InfinispanUserSessionProvider implements UserSessionProvider, Sessi public InfinispanUserSessionProvider(KeycloakSession session, PersisterLastSessionRefreshStore persisterLastSessionRefreshStore, - InfinispanChangelogBasedTransaction sessionTx, - InfinispanChangelogBasedTransaction offlineSessionTx, - InfinispanChangelogBasedTransaction clientSessionTx, - InfinispanChangelogBasedTransaction offlineClientSessionTx, + UserSessionInfinispanChangelogBasedTransaction sessionTx, + UserSessionInfinispanChangelogBasedTransaction offlineSessionTx, + UserSessionInfinispanChangelogBasedTransaction clientSessionTx, + UserSessionInfinispanChangelogBasedTransaction offlineClientSessionTx, SessionFunction offlineSessionCacheEntryLifespanAdjuster, SessionFunction offlineClientSessionCacheEntryLifespanAdjuster) { this.session = session; diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProviderFactory.java index 4c139e67a94..f494bcb0503 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProviderFactory.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProviderFactory.java @@ -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 sessionTx, - InfinispanChangelogBasedTransaction offlineSessionTx, - InfinispanChangelogBasedTransaction clientSessionTx, - InfinispanChangelogBasedTransaction offlineClientSessionTx) {} + private record VolatileTransactions( + UserSessionInfinispanChangelogBasedTransaction sessionTx, + UserSessionInfinispanChangelogBasedTransaction offlineSessionTx, + UserSessionInfinispanChangelogBasedTransaction clientSessionTx, + UserSessionInfinispanChangelogBasedTransaction offlineClientSessionTx) {} private record PersistentTransaction(UserSessionPersistentChangelogBasedTransaction userTx, ClientSessionPersistentChangelogBasedTransaction clientTx) {} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/InfinispanChangelogBasedTransaction.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/InfinispanChangelogBasedTransaction.java index 4baeb28b577..3b885a7d98d 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/InfinispanChangelogBasedTransaction.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/InfinispanChangelogBasedTransaction.java @@ -167,7 +167,7 @@ public class InfinispanChangelogBasedTransaction imp long lifespanMs = cacheHolder.lifespanFunction().apply(realm, sessionUpdates.getClient(), sessionWrapper.getEntity()); long maxIdleTimeMs = cacheHolder.maxIdleFunction().apply(realm, sessionUpdates.getClient(), sessionWrapper.getEntity()); - MergedUpdate merged = MergedUpdate.computeUpdate(updateTasks, sessionWrapper, lifespanMs, maxIdleTimeMs); + MergedUpdate 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 imp // exists in transaction, avoid cache operation return updatesList.getEntityWrapper().getEntity(); } - SessionEntityWrapper existing = cacheHolder.cache().putIfAbsent(key, session, lifespan, TimeUnit.MILLISECONDS, maxIdle, TimeUnit.MILLISECONDS); + SessionEntityWrapper 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 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 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; + } } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/PersistentSessionsChangelogBasedTransaction.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/PersistentSessionsChangelogBasedTransaction.java index 57be741d74d..b934c59987d 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/PersistentSessionsChangelogBasedTransaction.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/PersistentSessionsChangelogBasedTransaction.java @@ -128,7 +128,7 @@ abstract public class PersistentSessionsChangelogBasedTransaction merged = MergedUpdate.computeUpdate(sessionUpdates.getUpdateTasks(), sessionWrapper, lifespanMs, maxIdleTimeMs); + MergedUpdate 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 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 { // If the import fails, the transaction can continue with the data from the database. LOG.debugf(throwable, "Failed to import session %s", session); diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/UserSessionInfinispanChangelogBasedTransaction.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/UserSessionInfinispanChangelogBasedTransaction.java new file mode 100644 index 00000000000..9575fd7d923 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/UserSessionInfinispanChangelogBasedTransaction.java @@ -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. + *

+ * 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 extends InfinispanChangelogBasedTransaction { + public UserSessionInfinispanChangelogBasedTransaction(KeycloakSession kcSession, CacheHolder 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; + } +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/client/AuthenticatedClientSessionUpdater.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/client/AuthenticatedClientSessionUpdater.java index b226d633831..6a60091917c 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/client/AuthenticatedClientSessionUpdater.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/client/AuthenticatedClientSessionUpdater.java @@ -104,7 +104,7 @@ public class AuthenticatedClientSessionUpdater extends BaseUpdater + * 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); + } } diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/KeycloakModelTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/KeycloakModelTest.java index 059455f555e..cc83635e205 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/KeycloakModelTest.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/KeycloakModelTest.java @@ -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> 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 MODEL_PARAMETERS; protected static final Config CONFIG = new Config(KeycloakModelTest::useDefaultFactory);