Added section on recommended isolation level to db guides

Closes #44611

Signed-off-by: Sebastian Schuster <sebastian.schuster@bosch.com>
Signed-off-by: Alexander Schwartz <alexander.schwartz@ibm.com>
Co-authored-by: Alexander Schwartz <alexander.schwartz@ibm.com>
This commit is contained in:
Sebastian Schuster
2025-12-05 14:48:31 +01:00
committed by GitHub
parent 52bf0face3
commit b5178a2bec
9 changed files with 175 additions and 6 deletions

View File

@@ -54,6 +54,18 @@ The SPIFFE Identity Provider preview feature now uses the `trustDomain` configur
was done to make it more explicit that a `trustDomain` is configured and not the `iss` of the token, which is
usually not set in SPIFFE JWT SVIDs.
=== Running on Microsoft SQL Server now recommends READ_COMMITTED_SNAPSHOT
On MS SQL Server, the default transaction isolation level is `READ_COMMITTED`, which can lead to deadlocks during high load. Therefore, the recommended isolation level for {project_name} is `READ_COMMITTED_SNAPSHOT`.
Use the following statement to changes this for your database:
[source,sql]
----
ALTER DATABASE [your-database-name] SET READ_COMMITTED_SNAPSHOT ON;
----
=== `session_state` and `sid` are no longer UUIDs
In OpenID connect, there are several places where the protocol shares a `session_state` and a `sid`.

View File

@@ -361,6 +361,17 @@ Beginning with MySQL 8.0.30, MySQL supports generated invisible primary keys for
If this feature is enabled, the database schema initialization and also migrations will fail with the error message `Multiple primary key defined (1068)`.
You then need to disable it by setting the parameter `sql_generate_invisible_primary_key` to `OFF` in your MySQL server configuration before installing or upgrading {project_name}.
== Preparing for MS SQL server
On MS SQL Server, the default transaction isolation level is `READ_COMMITTED`, which can lead to deadlocks during high load. Therefore, the recommended isolation level for {project_name} is `READ_COMMITTED_SNAPSHOT`. This isolation level is used by default on Azure SQL.
However, on MS SQL Server, the database isolation level needs to be modified by executing the following command on your database:
[source,sql]
----
ALTER DATABASE <your-database-name> SET READ_COMMITTED_SNAPSHOT ON;
----
== Changing database locking timeout in a cluster configuration
Because cluster nodes can boot concurrently, they take extra time for database actions. For example, a booting server instance may perform some database migration, importing, or first time initializations. A database lock prevents start actions from conflicting with each other when cluster nodes boot up concurrently.

View File

@@ -99,6 +99,7 @@ public class QuarkusJpaConnectionProviderFactory extends AbstractJpaConnectionPr
super.postInit(factory);
checkMySQLWaitTimeout();
checkMSSQLIsolationLevel();
String id = null;
String version = null;
@@ -322,4 +323,30 @@ public class QuarkusJpaConnectionProviderFactory extends AbstractJpaConnectionPr
logger.warnf(e, "Unable to validate %s 'wait_timeout' due to database exception", vendor);
}
}
private void checkMSSQLIsolationLevel() {
String db = Configuration.getConfigValue(DatabaseOptions.DB).getValue();
Database.Vendor vendor = Database.getVendor(db).orElseThrow();
if (Database.Vendor.MSSQL != vendor) {
return;
}
try (Connection connection = getConnection();
Statement statement = connection.createStatement();
Statement statement2 = connection.createStatement();
ResultSet rs = statement.executeQuery("DBCC USEROPTIONS");
ResultSet dbnameRs = statement2.executeQuery("SELECT DB_NAME() as db")) {
dbnameRs.next();
String dbName = dbnameRs.getString(1);
while (rs.next()) {
String option = rs.getString(1);
String value = rs.getString(2);
if ("isolation level".equalsIgnoreCase(option) && (!"read committed snapshot".equalsIgnoreCase(value))) {
logger.warnf("%s 'isolation level' for database '%s' is set to '%s'. Keycloak recommends 'read committed snapshot' isolation level to avoid deadlocks under high load. Please adjust the isolation level by executing 'ALTER DATABASE %s SET READ_COMMITTED_SNAPSHOT ON'.", vendor, dbName, rs.getString(2), dbName);
}
}
} catch (SQLException e) {
logger.warnf(e, "Unable to validate %s 'isolation level' due to database exception", vendor);
}
}
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2021 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.it.storage.database;
import org.keycloak.it.junit5.extension.CLIResult;
import org.keycloak.it.junit5.extension.CLITest;
import org.keycloak.it.junit5.extension.WithDatabase;
import org.keycloak.quarkus.runtime.cli.command.AbstractAutoBuildCommand;
import io.quarkus.test.junit.main.Launch;
import org.junit.jupiter.api.Test;
@CLITest
@WithDatabase(alias = "mssql")
public class MssqlSQLTest extends BasicDatabaseTest {
@Override
protected void assertWrongUsername(CLIResult cliResult) {
cliResult.assertMessage("ErrorCode: 18456");
}
@Override
protected void assertWrongPassword(CLIResult cliResult) {
cliResult.assertMessage("ErrorCode: 18456");
}
@Test
@Launch({ "start", AbstractAutoBuildCommand.OPTIMIZED_BUILD_OPTION_LONG, "--http-enabled=true", "--hostname-strict=false" })
protected void testWarningIsolationLevel(CLIResult cliResult) {
cliResult.assertMessage("mssql 'isolation level' for database 'master' is set to 'read committed'.");
cliResult.assertStarted();
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2021 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.it.storage.database.dist;
import org.keycloak.it.junit5.extension.DistributionTest;
import org.keycloak.it.junit5.extension.WithDatabase;
import org.keycloak.it.storage.database.MssqlSQLTest;
import org.junit.jupiter.api.Tag;
@DistributionTest(removeBuildOptionsAfterBuild = true)
@WithDatabase(alias = "mssql")
@Tag(DistributionTest.STORAGE)
public class MssqlDistTest extends MssqlSQLTest {
}

View File

@@ -18,9 +18,12 @@
package org.keycloak.it.junit5.extension;
import java.time.Duration;
import java.util.logging.Logger;
import org.keycloak.it.utils.KeycloakDistribution;
import org.jboss.logmanager.Level;
import org.jboss.logmanager.LogManager;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.JdbcDatabaseContainer;
import org.testcontainers.containers.MSSQLServerContainer;
@@ -110,7 +113,20 @@ public class DatabaseContainer {
return configureJdbcContainer(new MySQLContainer<>(MYSQL));
case "mssql":
DockerImageName MSSQL = DockerImageName.parse(MSSQL_IMAGE).asCompatibleSubstituteFor("sqlserver");
return configureJdbcContainer(new MSSQLServerContainer<>(MSSQL));
return configureJdbcContainer(new MSSQLServerContainer(MSSQL) {
@Override
public void start() {
// avoid WARNING [com.microsoft.sqlserver.jdbc.internals.SQLServerConnection] (main) ConnectionID:32 ClientConnectionId: Prelogin error ...
Logger mssqlLogger = LogManager.getLogManager().getLogger("com.microsoft.sqlserver.jdbc.internals.SQLServerConnection");
java.util.logging.Level level = mssqlLogger.getLevel();
try {
mssqlLogger.setLevel(Level.ERROR);
super.start();
} finally {
mssqlLogger.setLevel(level);
}
}
});
case "tidb":
DockerImageName TIDB = DockerImageName.parse(TIDB_IMAGE).asCompatibleSubstituteFor("pingcap/tidb");
return configureJdbcContainer(new TiDBContainer(TIDB));

View File

@@ -9,6 +9,7 @@ import org.keycloak.testframework.config.Config;
import org.keycloak.testframework.logging.JBossLogConsumer;
import org.jboss.logging.Logger;
import org.testcontainers.containers.Container;
import org.testcontainers.containers.JdbcDatabaseContainer;
public abstract class AbstractContainerTestDatabase implements TestDatabase {
@@ -42,8 +43,14 @@ public abstract class AbstractContainerTestDatabase implements TestDatabase {
List<String> postStartCommand = getPostStartCommand();
if (postStartCommand != null) {
getLogger().tracev("Running post start command: {0}", String.join(" ", postStartCommand));
String result = container.execInContainer(postStartCommand.toArray(new String[0])).getStdout();
getLogger().tracev(result);
Container.ExecResult execResult = container.execInContainer(postStartCommand.toArray(new String[0]));
String stdout = execResult.getStdout();
String stderr = execResult.getStderr();
getLogger().tracev(stdout);
getLogger().tracev(stderr);
if (execResult.getExitCode() != 0) {
throw new RuntimeException("Post start command failed with exit code: " + execResult.getExitCode() + ". stdout: " + stdout + ". stderr: " + stderr);
}
}
} catch (IOException | InterruptedException e) {
throw new RuntimeException(e);

View File

@@ -5,6 +5,8 @@ import java.util.List;
import org.keycloak.testframework.util.ContainerImages;
import org.jboss.logging.Logger;
import org.jboss.logmanager.Level;
import org.jboss.logmanager.LogManager;
import org.testcontainers.containers.JdbcDatabaseContainer;
import org.testcontainers.containers.MSSQLServerContainer;
import org.testcontainers.utility.DockerImageName;
@@ -43,12 +45,27 @@ class MSSQLServerTestDatabase extends AbstractContainerTestDatabase {
@Override
public String getJdbcUrl(boolean internal) {
return super.getJdbcUrl(internal) + ";integratedSecurity=false;encrypt=false;trustServerCertificate=true;sendStringParametersAsUnicode=false;";
return super.getJdbcUrl(internal) + ";integratedSecurity=false;encrypt=false;trustServerCertificate=true;sendStringParametersAsUnicode=false;databaseName=" + getDatabase();
}
@Override
public void start(DatabaseConfiguration config) {
// avoid WARNING [com.microsoft.sqlserver.jdbc.internals.SQLServerConnection] (main) ConnectionID:32 ClientConnectionId: Prelogin error ...
java.util.logging.Logger mssqlLogger = LogManager.getLogManager().getLogger("com.microsoft.sqlserver.jdbc.internals.SQLServerConnection");
java.util.logging.Level level = mssqlLogger.getLevel();
try {
mssqlLogger.setLevel(Level.ERROR);
super.start(config);
} finally {
mssqlLogger.setLevel(level);
}
}
@Override
public List<String> getPostStartCommand() {
return List.of("/opt/mssql-tools18/bin/sqlcmd", "-U", "sa", "-P", getPassword(), "-No", "-Q", "CREATE DATABASE " + getDatabase());
return List.of("/opt/mssql-tools18/bin/sqlcmd", "-U", "sa", "-P", getPassword(), "-No", "-Q", "CREATE DATABASE " + getDatabase() + "; " +
// READ_COMMITTED_SNAPSHOT is recommended for MSSQL to avoid deadlocks
"ALTER DATABASE " + getDatabase() + " SET READ_COMMITTED_SNAPSHOT ON;");
}
@Override

View File

@@ -531,7 +531,7 @@
<docker.database.image>${mssql.container}</docker.database.image>
<docker.database.port>1433</docker.database.port>
<docker.database.skip>false</docker.database.skip>
<docker.database.postStart>/opt/mssql-tools18/bin/sqlcmd -e -U sa -P ${keycloak.connectionsJpa.password} -No -d master -Q CREATE\ DATABASE\ ${keycloak.connectionsJpa.database}</docker.database.postStart>
<docker.database.postStart>/opt/mssql-tools18/bin/sqlcmd -e -U sa -P ${keycloak.connectionsJpa.password} -No -d master -Q CREATE\ DATABASE\ ${keycloak.connectionsJpa.database};\ ALTER\ DATABASE\ ${keycloak.connectionsJpa.database}\ SET\ READ_COMMITTED_SNAPSHOT\ ON</docker.database.postStart>
<docker.database.cmd>/bin/sh -c /opt/mssql/bin/sqlservr</docker.database.cmd>
<docker.database.wait-for-log-regex>Recovery is complete. This is an informational message only. No user action is required.</docker.database.wait-for-log-regex>
<keycloak.storage.connections.vendor>mssql</keycloak.storage.connections.vendor>