From 2f4f36eabc6889822a7ee6043e0358ca967e1808 Mon Sep 17 00:00:00 2001 From: Pedro Ruivo Date: Fri, 23 Jan 2026 15:28:34 +0000 Subject: [PATCH] Add realm id column to offline_client_session table Closes #44424 Signed-off-by: Pedro Ruivo <1492066+pruivo@users.noreply.github.com> Co-authored-by: Pedro Ruivo <1492066+pruivo@users.noreply.github.com> --- .../topics/changes/changes-26_6_0.adoc | 7 ++ ...pdate26_6_0_OfflineClientSessionRealm.java | 111 ++++++++++++++++++ .../JpaUserSessionPersisterProvider.java | 12 +- .../PersistentClientSessionEntity.java | 21 +++- .../session/PersistentUserSessionEntity.java | 4 - .../META-INF/jpa-changelog-26.6.0.xml | 42 +++++++ 6 files changed, 182 insertions(+), 15 deletions(-) create mode 100644 model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/custom/JpaUpdate26_6_0_OfflineClientSessionRealm.java diff --git a/docs/documentation/upgrading/topics/changes/changes-26_6_0.adoc b/docs/documentation/upgrading/topics/changes/changes-26_6_0.adoc index 48431b09806..07252772ca2 100644 --- a/docs/documentation/upgrading/topics/changes/changes-26_6_0.adoc +++ b/docs/documentation/upgrading/topics/changes/changes-26_6_0.adoc @@ -65,6 +65,13 @@ It does not affect any explicitly configured clock skew for example in identity All the `base` themes are now tagged as **abstract**, and they are not listed in the admin console to be selected (**Realm Settings** -> **Themes** tab). They were always intended to be only extended but not used directly. If you use one of them, it will continue working (or not working) in the same way but cannot be selected using the admin console anymore. Please select one of the available default themes or create your own one. +=== New database indexes on the `OFFLINE_CLIENT_SESSION` table + +The `OFFLINE_CLIENT_SESSION` table now contains two additional index `IDX_OFFLINE_CSS_BY_CLIENT_AND_REALM` and `IDX_OFFLINE_CSS_BY_USER_SESSION_AND_OFFLINE` to improve performance. + +If the table contains more than 300000 entries, {project_name} will skip the index creation by default during the automatic schema migration and instead log the SQL statement on the console during migration to be applied manually after {project_name}'s startup. +See the link:{upgradingguide_link}[{upgradingguide_name}] for details on how to configure a different limit. + // ------------------------ Deprecated features ------------------------ // == Deprecated features diff --git a/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/custom/JpaUpdate26_6_0_OfflineClientSessionRealm.java b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/custom/JpaUpdate26_6_0_OfflineClientSessionRealm.java new file mode 100644 index 00000000000..307a35b7e23 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/custom/JpaUpdate26_6_0_OfflineClientSessionRealm.java @@ -0,0 +1,111 @@ +/* + * 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.connections.jpa.updater.liquibase.custom; + +import liquibase.database.core.MSSQLDatabase; +import liquibase.database.core.MySQLDatabase; +import liquibase.database.core.OracleDatabase; +import liquibase.database.core.PostgresDatabase; +import liquibase.exception.CustomChangeException; +import liquibase.statement.core.RawParameterizedSqlStatement; + +public class JpaUpdate26_6_0_OfflineClientSessionRealm extends CustomKeycloakTask { + + @Override + protected void generateStatementsImpl() throws CustomChangeException { + String clientSessionTable = getTableName("OFFLINE_CLIENT_SESSION"); + String userSessionTable = getTableName("OFFLINE_USER_SESSION"); + + if (database instanceof PostgresDatabase) { + generateUpdateQueryForPostgresSQL(clientSessionTable, userSessionTable); + return; + } + + if (database instanceof MySQLDatabase) { + // and MariaDB + generateUpdateQueryForMySQL(clientSessionTable, userSessionTable); + return; + } + + if (database instanceof MSSQLDatabase) { + generateUpdateQueryForMSSQL(clientSessionTable, userSessionTable); + return; + } + + if (database instanceof OracleDatabase) { + generateUpdateQueryForOracle(clientSessionTable, userSessionTable); + return; + } + + // H2 and others, very slow with O(n^2) complexity + // It is standard SQL queries, it *must* be compatible with all vendors (fingers crossed) + generateUpdateQueryUsingStandardSQL(clientSessionTable, userSessionTable); + } + + @Override + protected String getTaskId() { + return "Sets the realm column in offline_client_session"; + } + + private void generateUpdateQueryUsingStandardSQL(String clientSessionTable, String userSessionTable) { + statements.add(new RawParameterizedSqlStatement(""" + UPDATE %s cs + SET cs.realm_id = ( + SELECT us.realm_id + FROM %s us + WHERE us.user_session_id = cs.user_session_id AND cs.offline_flag = us.offline_flag + ) + WHERE cs.realm_id IS NULL""" + .formatted(clientSessionTable, userSessionTable))); + } + + private void generateUpdateQueryForOracle(String clientSessionTable, String userSessionTable) { + statements.add(new RawParameterizedSqlStatement(""" + MERGE INTO %s cs + USING %s us + ON (cs.user_session_id = us.user_session_id AND cs.offline_flag = us.offline_flag) + WHEN MATCHED THEN + UPDATE SET cs.realm_id = us.realm_id""" + .formatted(clientSessionTable, userSessionTable))); + } + + private void generateUpdateQueryForMSSQL(String clientSessionTable, String userSessionTable) { + statements.add(new RawParameterizedSqlStatement(""" + UPDATE cs + SET cs.realm_id = us.realm_id + FROM %s cs + INNER JOIN %s us ON cs.user_session_id = us.user_session_id AND cs.offline_flag = us.offline_flag AND cs.realm_id IS NULL""" + .formatted(clientSessionTable, userSessionTable))); + } + + private void generateUpdateQueryForMySQL(String clientSessionTable, String userSessionTable) { + statements.add(new RawParameterizedSqlStatement(""" + UPDATE %s cs + INNER JOIN %s us ON cs.user_session_id = us.user_session_id AND cs.offline_flag = us.offline_flag AND cs.realm_id IS NULL + SET cs.realm_id = us.realm_id""" + .formatted(clientSessionTable, userSessionTable))); + } + + private void generateUpdateQueryForPostgresSQL(String clientSessionTable, String userSessionTable) { + statements.add(new RawParameterizedSqlStatement(""" + UPDATE %s cs + SET realm_id = us.realm_id + FROM %s us + WHERE cs.user_session_id = us.user_session_id AND cs.offline_flag = us.offline_flag AND cs.realm_id IS NULL""" + .formatted(clientSessionTable, userSessionTable))); + } +} diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java index 1baa8ecd828..6cbe65fc7eb 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java @@ -133,6 +133,7 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv entity.setTimestamp(clientSession.getTimestamp()); entity.setData(model.getData()); + entity.setRealmId(adapter.getRealm().getId()); if (!exists) { em.persist(entity); @@ -272,14 +273,9 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv @Override public Map getUserSessionsCountsByClients(RealmModel realm, boolean offline) { - - String offlineStr = offlineToString(offline); - - TypedQuery query = em.createNamedQuery("findClientSessionsClientIds", Object[].class); - - query.setParameter("offline", offlineStr); - query.setParameter("realmId", realm.getId()); - query.setParameter("lastSessionRefresh", calculateOldestSessionTime(realm, offline)); + TypedQuery query = em.createNamedQuery("findClientSessionsClientIds", Object[].class) + .setParameter("offline", offlineToString(offline)) + .setParameter("realmId", realm.getId()); return closing(query.getResultStream()) .collect(Collectors.toMap(row -> { diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentClientSessionEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentClientSessionEntity.java index efe34ca6915..8cd8bdb8d7b 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentClientSessionEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentClientSessionEntity.java @@ -35,7 +35,7 @@ import org.hibernate.annotations.DynamicUpdate; * @author Marek Posolda */ @NamedQueries({ - @NamedQuery(name="deleteClientSessionsByRealm", query="delete from PersistentClientSessionEntity sess where sess.userSessionId IN (select u.userSessionId from PersistentUserSessionEntity u where u.realmId = :realmId)"), + @NamedQuery(name="deleteClientSessionsByRealm", query="delete from PersistentClientSessionEntity sess where sess.realmId = :realmId"), @NamedQuery(name="deleteClientSessionsByClient", query="delete from PersistentClientSessionEntity sess where sess.clientId = :clientId and sess.clientId != 'external'"), @NamedQuery(name="deleteClientSessionsByExternalClient", query="delete from PersistentClientSessionEntity sess where sess.clientStorageProvider = :clientStorageProvider and sess.externalClientId = :externalClientId and sess.clientStorageProvider != 'internal'"), @NamedQuery(name="deleteClientSessionsByClientStorageProvider", query="delete from PersistentClientSessionEntity sess where sess.clientStorageProvider = :clientStorageProvider"), @@ -44,14 +44,18 @@ import org.hibernate.annotations.DynamicUpdate; @NamedQuery(name="deleteClientSessionsByUserSessions", query="delete from PersistentClientSessionEntity sess where sess.userSessionId in (:userSessionId) and sess.offline = :offline"), // The query "deleteExpiredClientSessions" is deprecated (since 26.5) and may be removed in the future. @NamedQuery(name="deleteExpiredClientSessions", query="delete from PersistentClientSessionEntity sess where sess.offline = :offline AND sess.userSessionId IN (select u.userSessionId from PersistentUserSessionEntity u where u.realmId = :realmId AND u.offline = :offline AND u.lastSessionRefresh < :lastSessionRefresh)"), - @NamedQuery(name="deleteClientSessionsByRealmSessionType", query="delete from PersistentClientSessionEntity sess where sess.offline = :offline AND sess.userSessionId IN (select u.userSessionId from PersistentUserSessionEntity u where u.realmId = :realmId and u.offline = :offline)"), + @NamedQuery(name="deleteClientSessionsByRealmSessionType", query="delete from PersistentClientSessionEntity sess where sess.offline = :offline AND sess.realmId = :realmId"), @NamedQuery(name="findClientSessionsByUserSession", query="select sess from PersistentClientSessionEntity sess where sess.userSessionId=:userSessionId and sess.offline = :offline"), @NamedQuery(name="findClientSessionsOrderedByIdInterval", query="select sess from PersistentClientSessionEntity sess where sess.offline = :offline and sess.userSessionId >= :fromSessionId and sess.userSessionId <= :toSessionId order by sess.userSessionId"), @NamedQuery(name="findClientSessionsOrderedByIdExact", query="select sess from PersistentClientSessionEntity sess where sess.offline = :offline and sess.userSessionId IN (:userSessionIds)"), @NamedQuery(name="findClientSessionsCountByClient", query="select count(sess) from PersistentClientSessionEntity sess where sess.offline = :offline and sess.clientId = :clientId and sess.clientId != 'external'"), @NamedQuery(name="findClientSessionsCountByExternalClient", query="select count(sess) from PersistentClientSessionEntity sess where sess.offline = :offline and sess.clientStorageProvider = :clientStorageProvider and sess.externalClientId = :externalClientId and sess.clientStorageProvider != 'internal'"), @NamedQuery(name="findClientSessionsByUserSessionAndClient", query="select sess from PersistentClientSessionEntity sess where sess.userSessionId=:userSessionId and sess.offline = :offline and sess.clientId=:clientId and sess.clientId != 'external'"), - @NamedQuery(name="findClientSessionsByUserSessionAndExternalClient", query="select sess from PersistentClientSessionEntity sess where sess.userSessionId=:userSessionId and sess.offline = :offline and sess.clientStorageProvider = :clientStorageProvider and sess.externalClientId = :externalClientId and sess.clientStorageProvider != 'internal'") + @NamedQuery(name="findClientSessionsByUserSessionAndExternalClient", query="select sess from PersistentClientSessionEntity sess where sess.userSessionId=:userSessionId and sess.offline = :offline and sess.clientStorageProvider = :clientStorageProvider and sess.externalClientId = :externalClientId and sess.clientStorageProvider != 'internal'"), + @NamedQuery(name="findClientSessionsClientIds", query="SELECT sess.clientId, sess.externalClientId, sess.clientStorageProvider, count(sess)" + + " FROM PersistentClientSessionEntity sess" + + " WHERE sess.offline = :offline AND sess.realmId = :realmId" + + " GROUP BY sess.clientId, sess.externalClientId, sess.clientStorageProvider"), }) @Table(name="OFFLINE_CLIENT_SESSION") @Entity @@ -91,6 +95,9 @@ public class PersistentClientSessionEntity { @Column(name="DATA") protected String data; + @Column(name = "REALM_ID", length = 36) + protected String realmId; + public String getUserSessionId() { return userSessionId; } @@ -147,6 +154,14 @@ public class PersistentClientSessionEntity { this.data = data; } + public String getRealmId() { + return realmId; + } + + public void setRealmId(String realmId) { + this.realmId = realmId; + } + public static class Key implements Serializable { protected String userSessionId; diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentUserSessionEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentUserSessionEntity.java index d0d7c342342..ddb511e4b3e 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentUserSessionEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentUserSessionEntity.java @@ -63,10 +63,6 @@ import org.hibernate.annotations.DynamicUpdate; @NamedQuery(name="findUserSessionsByExternalClientId", query="SELECT sess FROM PersistentUserSessionEntity sess INNER JOIN PersistentClientSessionEntity clientSess " + " ON sess.userSessionId = clientSess.userSessionId AND clientSess.clientStorageProvider = :clientStorageProvider AND sess.offline = clientSess.offline AND clientSess.externalClientId = :externalClientId WHERE sess.offline = :offline " + " AND sess.realmId = :realmId AND sess.lastSessionRefresh >= :lastSessionRefresh ORDER BY sess.userSessionId"), - @NamedQuery(name="findClientSessionsClientIds", query="SELECT clientSess.clientId, clientSess.externalClientId, clientSess.clientStorageProvider, count(clientSess)" + - " FROM PersistentClientSessionEntity clientSess INNER JOIN PersistentUserSessionEntity sess ON clientSess.userSessionId = sess.userSessionId AND sess.offline = clientSess.offline" + - " WHERE sess.offline = :offline AND sess.realmId = :realmId AND sess.lastSessionRefresh >= :lastSessionRefresh" + - " GROUP BY clientSess.clientId, clientSess.externalClientId, clientSess.clientStorageProvider"), @NamedQuery(name = "findUserSessionAndDataWithNullRememberMeLastRefresh", query = "SELECT sess.userSessionId, sess.userId, sess.data" + " FROM PersistentUserSessionEntity sess" + diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-26.6.0.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.6.0.xml index 949a3ed6579..6fb98db1186 100644 --- a/model/jpa/src/main/resources/META-INF/jpa-changelog-26.6.0.xml +++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.6.0.xml @@ -55,4 +55,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +