Add transaction tests to model tests

Closes: #15890
This commit is contained in:
Michal Hajas 2023-01-18 17:57:52 +01:00 committed by Hynek Mlnařík
parent b7ba9f9af0
commit eb59fdb772
8 changed files with 331 additions and 3 deletions

View file

@ -21,6 +21,7 @@ import static org.keycloak.models.map.storage.jpa.updater.MapJpaUpdaterProvider.
import java.sql.Connection; import java.sql.Connection;
import java.sql.DatabaseMetaData; import java.sql.DatabaseMetaData;
import java.sql.DriverManager; import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@ -165,6 +166,7 @@ public class JpaMapStorageProviderFactory implements
private Config.Scope config; private Config.Scope config;
private final String sessionProviderKey; private final String sessionProviderKey;
private final String sessionTxKey; private final String sessionTxKey;
private String databaseShortName;
// Object instances for each single JpaMapStorageProviderFactory instance per model type. // Object instances for each single JpaMapStorageProviderFactory instance per model type.
// Used to synchronize on when validating the model type area. // Used to synchronize on when validating the model type area.
@ -278,7 +280,23 @@ public class JpaMapStorageProviderFactory implements
} }
protected EntityManager getEntityManager() { protected EntityManager getEntityManager() {
return emf.createEntityManager(); EntityManager em = emf.createEntityManager();
// This is a workaround for Hibernate not supporting javax.persistence.lock.timeout
// config option for Postgresql/CockroachDB - https://hibernate.atlassian.net/browse/HHH-16071
if ("postgresql".equals(databaseShortName) || "cockroachdb".equals(databaseShortName)) {
Long lockTimeout = config.getLong("lockTimeout");
if (lockTimeout != null) {
em.unwrap(SessionImpl.class)
.doWork(connection -> {
PreparedStatement preparedStatement = connection.prepareStatement("SET LOCAL lock_timeout = '" + lockTimeout + "';");
preparedStatement.execute();
});
} else {
logger.warnf("Database %s used without lockTimeout option configured. This can result in deadlock where one connection waits for a pessimistic write lock forever.", databaseShortName);
}
}
return em;
} }
@Override @Override
@ -370,6 +388,11 @@ public class JpaMapStorageProviderFactory implements
properties.put("hibernate.show_sql", config.getBoolean("showSql", false)); properties.put("hibernate.show_sql", config.getBoolean("showSql", false));
properties.put("hibernate.format_sql", config.getBoolean("formatSql", true)); properties.put("hibernate.format_sql", config.getBoolean("formatSql", true));
properties.put("hibernate.dialect", config.get("driverDialect")); properties.put("hibernate.dialect", config.get("driverDialect"));
Integer lockTimeout = config.getInt("lockTimeout");
if (lockTimeout != null) {
// This property does not work for PostgreSQL/CockroachDB - https://hibernate.atlassian.net/browse/HHH-16071
properties.put("javax.persistence.lock.timeout", lockTimeout);
}
logger.trace("Creating EntityManagerFactory"); logger.trace("Creating EntityManagerFactory");
ParsedPersistenceXmlDescriptor descriptor = PersistenceXmlParser.locateIndividualPersistenceUnit( ParsedPersistenceXmlDescriptor descriptor = PersistenceXmlParser.locateIndividualPersistenceUnit(
@ -416,6 +439,7 @@ public class JpaMapStorageProviderFactory implements
MapJpaUpdaterProvider updater = session.getProvider(MapJpaUpdaterProvider.class); MapJpaUpdaterProvider updater = session.getProvider(MapJpaUpdaterProvider.class);
MapJpaUpdaterProvider.Status status = updater.validate(modelType, connection, config.get("schema")); MapJpaUpdaterProvider.Status status = updater.validate(modelType, connection, config.get("schema"));
databaseShortName = updater.getDatabaseShortName();
if (!status.equals(VALID)) { if (!status.equals(VALID)) {
update(modelType, connection, session); update(modelType, connection, session);

View file

@ -45,6 +45,7 @@ public class MapJpaLiquibaseUpdaterProvider implements MapJpaUpdaterProvider {
private static final Logger logger = Logger.getLogger(MapJpaLiquibaseUpdaterProvider.class); private static final Logger logger = Logger.getLogger(MapJpaLiquibaseUpdaterProvider.class);
private final KeycloakSession session; private final KeycloakSession session;
private String databaseShortName;
public MapJpaLiquibaseUpdaterProvider(KeycloakSession session) { public MapJpaLiquibaseUpdaterProvider(KeycloakSession session) {
this.session = session; this.session = session;
@ -187,6 +188,7 @@ public class MapJpaLiquibaseUpdaterProvider implements MapJpaUpdaterProvider {
try (Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnectionFromPool(connection.unwrap(Connection.class)))) { try (Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnectionFromPool(connection.unwrap(Connection.class)))) {
// if the database is cockroachdb, use the aggregate changelog (see GHI #11230). // if the database is cockroachdb, use the aggregate changelog (see GHI #11230).
String changelog = database instanceof CockroachDatabase ? "META-INF/jpa-aggregate-changelog.xml" : "META-INF/jpa-" + modelName + "-changelog.xml"; String changelog = database instanceof CockroachDatabase ? "META-INF/jpa-aggregate-changelog.xml" : "META-INF/jpa-" + modelName + "-changelog.xml";
databaseShortName = database.getShortName();
return liquibaseProvider.getLiquibaseForCustomUpdate(connection, defaultSchema, changelog, this.getClass().getClassLoader(), "databasechangelog"); return liquibaseProvider.getLiquibaseForCustomUpdate(connection, defaultSchema, changelog, this.getClass().getClassLoader(), "databasechangelog");
} catch (SQLException e) { } catch (SQLException e) {
throw new LiquibaseException(e); throw new LiquibaseException(e);
@ -197,4 +199,8 @@ public class MapJpaLiquibaseUpdaterProvider implements MapJpaUpdaterProvider {
public void close() { public void close() {
} }
@Override
public String getDatabaseShortName() {
return databaseShortName;
}
} }

View file

@ -68,4 +68,8 @@ public interface MapJpaUpdaterProvider extends Provider {
*/ */
void export(Class<?> modelType, Connection connection, String defaultSchema, File file); void export(Class<?> modelType, Connection connection, String defaultSchema, File file);
/**
* Returns an all-lower-case short name of the used database.
*/
String getDatabaseShortName();
} }

View file

@ -95,7 +95,8 @@ public class JpaMapStorage extends KeycloakModelParameters {
.config("user", POSTGRES_DB_USER) .config("user", POSTGRES_DB_USER)
.config("password", POSTGRES_DB_PASSWORD) .config("password", POSTGRES_DB_PASSWORD)
.config("driver", "org.postgresql.Driver") .config("driver", "org.postgresql.Driver")
.config("driverDialect", "org.keycloak.models.map.storage.jpa.hibernate.dialect.JsonbPostgreSQL95Dialect"); .config("driverDialect", "org.keycloak.models.map.storage.jpa.hibernate.dialect.JsonbPostgreSQL95Dialect")
.config("lockTimeout", "1000");
cf.spi(AuthenticationSessionSpi.PROVIDER_ID).provider(MapRootAuthenticationSessionProviderFactory.PROVIDER_ID) .config(STORAGE_CONFIG, JpaMapStorageProviderFactory.PROVIDER_ID) cf.spi(AuthenticationSessionSpi.PROVIDER_ID).provider(MapRootAuthenticationSessionProviderFactory.PROVIDER_ID) .config(STORAGE_CONFIG, JpaMapStorageProviderFactory.PROVIDER_ID)
.spi("client").provider(MapClientProviderFactory.PROVIDER_ID) .config(STORAGE_CONFIG, JpaMapStorageProviderFactory.PROVIDER_ID) .spi("client").provider(MapClientProviderFactory.PROVIDER_ID) .config(STORAGE_CONFIG, JpaMapStorageProviderFactory.PROVIDER_ID)

View file

@ -96,7 +96,8 @@ public class JpaMapStorageCockroachdb extends KeycloakModelParameters {
.config("user", COCKROACHDB_DB_USER) .config("user", COCKROACHDB_DB_USER)
.config("password", COCKROACHDB_DB_PASSWORD) .config("password", COCKROACHDB_DB_PASSWORD)
.config("driver", "org.postgresql.Driver") .config("driver", "org.postgresql.Driver")
.config("driverDialect", "org.keycloak.models.map.storage.jpa.hibernate.dialect.JsonbPostgreSQL95Dialect"); .config("driverDialect", "org.keycloak.models.map.storage.jpa.hibernate.dialect.JsonbPostgreSQL95Dialect")
.config("lockTimeout", "1000");
cf.spi(AuthenticationSessionSpi.PROVIDER_ID).provider(MapRootAuthenticationSessionProviderFactory.PROVIDER_ID) .config(STORAGE_CONFIG, JpaMapStorageProviderFactory.PROVIDER_ID) cf.spi(AuthenticationSessionSpi.PROVIDER_ID).provider(MapRootAuthenticationSessionProviderFactory.PROVIDER_ID) .config(STORAGE_CONFIG, JpaMapStorageProviderFactory.PROVIDER_ID)
.spi("client").provider(MapClientProviderFactory.PROVIDER_ID) .config(STORAGE_CONFIG, JpaMapStorageProviderFactory.PROVIDER_ID) .spi("client").provider(MapClientProviderFactory.PROVIDER_ID) .config(STORAGE_CONFIG, JpaMapStorageProviderFactory.PROVIDER_ID)

View file

@ -0,0 +1,185 @@
/*
* Copyright 2023 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.testsuite.model.transaction;
import org.junit.Test;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RealmProvider;
import org.keycloak.models.map.storage.MapStorageProvider;
import org.keycloak.models.map.storage.jpa.JpaMapStorageProviderFactory;
import org.keycloak.testsuite.model.KeycloakModelTest;
import org.keycloak.testsuite.model.RequireProvider;
import org.keycloak.testsuite.model.util.TransactionController;
import org.keycloak.utils.LockObjectsForModification;
import javax.persistence.OptimisticLockException;
import javax.persistence.PessimisticLockException;
import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.instanceOf;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.internal.matchers.ThrowableCauseMatcher.hasCause;
import static org.keycloak.testsuite.model.util.KeycloakAssertions.assertException;
@RequireProvider(RealmProvider.class)
public class StorageTransactionTest extends KeycloakModelTest {
private String realmId;
@Override
protected void createEnvironment(KeycloakSession s) {
RealmModel r = s.realms().createRealm("1");
r.setDefaultRole(s.roles().addRealmRole(r, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + r.getName()));
r.setAttribute("k1", "v1");
realmId = r.getId();
}
@Override
protected void cleanEnvironment(KeycloakSession s) {
s.realms().removeRealm(realmId);
}
@Override
protected boolean isUseSameKeycloakSessionFactoryForAllThreads() {
return true;
}
@Test
public void testTwoTransactionsSequentially() throws Exception {
TransactionController tx1 = new TransactionController(getFactory());
TransactionController tx2 = new TransactionController(getFactory());
tx1.begin();
assertThat(
tx1.runStep(session -> {
session.realms().getRealm(realmId).setAttribute("k2", "v1");
return session.realms().getRealm(realmId).getAttribute("k2");
}), equalTo("v1"));
tx1.commit();
tx2.begin();
assertThat(
tx2.runStep(session -> session.realms().getRealm(realmId).getAttribute("k2")),
equalTo("v1"));
tx2.commit();
}
@Test
public void testRepeatableRead() {
TransactionController tx1 = new TransactionController(getFactory());
TransactionController tx2 = new TransactionController(getFactory());
TransactionController tx3 = new TransactionController(getFactory());
tx1.begin();
tx2.begin();
tx3.begin();
// Read original value in tx1
assertThat(
tx1.runStep(session -> session.realms().getRealm(realmId).getAttribute("k1")),
equalTo("v1"));
// change value to new in tx2
tx2.runStep(session -> {
session.realms().getRealm(realmId).setAttribute("k1", "v2");
return null;
});
tx2.commit();
// tx1 should still return the value that already read
assertThat(
tx1.runStep(session -> session.realms().getRealm(realmId).getAttribute("k1")),
equalTo("v1"));
// tx3 should return the new value
assertThat(
tx3.runStep(session -> session.realms().getRealm(realmId).getAttribute("k1")),
equalTo("v2"));
tx1.commit();
tx3.commit();
}
@Test
// LockObjectForModification is currently used only in map-jpa
@RequireProvider(value = MapStorageProvider.class, only = JpaMapStorageProviderFactory.PROVIDER_ID)
public void testLockObjectForModification() {
TransactionController tx1 = new TransactionController(getFactory());
TransactionController tx2 = new TransactionController(getFactory());
TransactionController tx3 = new TransactionController(getFactory());
tx1.begin();
tx2.begin();
// tx1 acquires lock
tx1.runStep(session -> LockObjectsForModification.lockRealmsForModification(session, () -> session.realms().getRealm(realmId)));
// tx2 should fail as tx1 locked the realm
assertException(() -> tx2.runStep(session -> LockObjectsForModification.lockRealmsForModification(session, () -> session.realms().getRealm(realmId))),
allOf(instanceOf(ModelException.class),
hasCause(instanceOf(PessimisticLockException.class))));
// end both transactions
tx2.rollback();
tx1.commit();
// start new transaction and read again, it should be successful
tx3.begin();
tx3.runStep(session -> LockObjectsForModification.lockRealmsForModification(session, () -> session.realms().getRealm(realmId)));
tx3.commit();
}
@Test
// Optimistic locking works only with map-jpa
@RequireProvider(value = MapStorageProvider.class, only = JpaMapStorageProviderFactory.PROVIDER_ID)
public void testOptimisticLockingException() {
withRealm(realmId, (session, realm) -> {
realm.setDisplayName("displayName1");
return null;
});
TransactionController tx1 = new TransactionController(getFactory());
TransactionController tx2 = new TransactionController(getFactory());
// tx1 acquires lock
tx1.begin();
tx2.begin();
// both transactions touch the same entity
tx1.runStep(session -> {
session.realms().getRealm(realmId).setDisplayName("displayName2");
return null;
});
tx2.runStep(session -> {
session.realms().getRealm(realmId).setDisplayName("displayName3");
return null;
});
// tx1 transaction should be successful
tx1.commit();
// tx2 should fail as tx1 already changed the value
assertException(tx2::commit,
allOf(instanceOf(ModelException.class),
hasCause(instanceOf(OptimisticLockException.class))));
}
}

View file

@ -0,0 +1,55 @@
/*
* Copyright 2023 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.testsuite.model.util;
import org.hamcrest.Matcher;
import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat;
public class KeycloakAssertions {
/**
* Runs {@code task} and checks whether the execution resulted in
* an exception that matches {@code matcher}. The method fails also
* when no exception is thrown.
*
* @param task task
* @param matcher matcher that the exception should match
*/
public static void assertException(Runnable task, Matcher<? super Throwable> matcher) {
Throwable ex = catchException(task);
assertThat(ex, allOf(notNullValue(), matcher));
}
/**
* Runs the {@code task} and returns any throwable that is thrown.
* If not exception is thrown, the method returns {@code null}
*
* @param task task
*/
public static Throwable catchException(Runnable task) {
try {
task.run();
return null;
} catch (Throwable ex) {
return ex;
}
}
}

View file

@ -0,0 +1,52 @@
/*
* Copyright 2023 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.testsuite.model.util;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.KeycloakTransactionManager;
import java.util.function.Function;
public class TransactionController {
private final KeycloakSession session;
public TransactionController(KeycloakSessionFactory sessionFactory) {
session = sessionFactory.create();
}
public void begin() {
getTransactionManager().begin();
}
public void commit() {
getTransactionManager().commit();
}
public void rollback() {
getTransactionManager().rollback();
}
public <R> R runStep(Function<KeycloakSession, R> task) {
return task.apply(session);
}
private KeycloakTransactionManager getTransactionManager() {
return session.getTransactionManager();
}
}