From 8da768a514620199169f4f125eb612534cebc0ff Mon Sep 17 00:00:00 2001 From: mposolda Date: Wed, 2 Mar 2016 12:04:10 +0100 Subject: [PATCH] KEYCLOAK-2529 Concurrent startup by more cluster nodes at the same time. Added DBLockProvider --- .../reference/en/en-US/modules/clustering.xml | 23 ++ .../DefaultJpaConnectionProviderFactory.java | 12 +- .../jpa/JpaConnectionProviderFactory.java | 7 + .../jpa/updater/JpaUpdaterProvider.java | 3 +- .../LiquibaseJpaUpdaterProvider.java | 157 +----------- .../LiquibaseJpaUpdaterProviderFactory.java | 2 +- .../DefaultLiquibaseConnectionProvider.java | 235 ++++++++++++++++++ .../conn/LiquibaseConnectionProvider.java | 33 +++ .../LiquibaseConnectionProviderFactory.java | 26 ++ .../conn/LiquibaseConnectionSpi.java | 48 ++++ .../lock/CustomInsertLockRecordGenerator.java | 64 +++++ .../liquibase/lock/CustomLockService.java | 175 +++++++++++++ .../liquibase/lock/DummyLockService.java | 38 +++ .../lock/LiquibaseDBLockProvider.java | 159 ++++++++++++ .../lock/LiquibaseDBLockProviderFactory.java | 79 ++++++ .../liquibase/lock/LockRetryException.java | 39 +++ .../META-INF/db2-jpa-changelog-master.xml | 1 + ...se.conn.LiquibaseConnectionProviderFactory | 18 ++ ...ycloak.models.dblock.DBLockProviderFactory | 18 ++ .../services/org.keycloak.provider.Spi | 3 +- ...DefaultMongoConnectionFactoryProvider.java | 94 ++++--- .../mongo/MongoConnectionProvider.java | 3 + .../mongo/MongoConnectionProviderFactory.java | 7 + .../mongo/lock/MongoDBLockProvider.java | 137 ++++++++++ .../lock/MongoDBLockProviderFactory.java | 84 +++++++ ...ycloak.models.dblock.DBLockProviderFactory | 35 +++ .../models/dblock/DBLockProvider.java | 44 ++++ .../models/dblock/DBLockProviderFactory.java | 31 +++ .../org/keycloak/models/dblock/DBLockSpi.java | 48 ++++ .../services/org.keycloak.provider.Spi | 1 + .../org/keycloak/services/ServicesLogger.java | 4 + .../services/managers/DBLockManager.java | 87 +++++++ .../resources/KeycloakApplication.java | 62 +++-- testsuite/integration/pom.xml | 45 +--- .../keycloak/testsuite/model/DBLockTest.java | 173 +++++++++++++ .../keycloak/testsuite/model/ModelTest.java | 2 +- .../src/test/resources/log4j.properties | 2 +- 37 files changed, 1755 insertions(+), 244 deletions(-) create mode 100644 model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/conn/DefaultLiquibaseConnectionProvider.java create mode 100644 model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/conn/LiquibaseConnectionProvider.java create mode 100644 model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/conn/LiquibaseConnectionProviderFactory.java create mode 100644 model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/conn/LiquibaseConnectionSpi.java create mode 100644 model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/CustomInsertLockRecordGenerator.java create mode 100644 model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/CustomLockService.java create mode 100644 model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/DummyLockService.java create mode 100644 model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/LiquibaseDBLockProvider.java create mode 100644 model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/LiquibaseDBLockProviderFactory.java create mode 100644 model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/LockRetryException.java create mode 100644 model/jpa/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory create mode 100644 model/jpa/src/main/resources/META-INF/services/org.keycloak.models.dblock.DBLockProviderFactory create mode 100644 model/mongo/src/main/java/org/keycloak/connections/mongo/lock/MongoDBLockProvider.java create mode 100644 model/mongo/src/main/java/org/keycloak/connections/mongo/lock/MongoDBLockProviderFactory.java create mode 100644 model/mongo/src/main/resources/META-INF/services/org.keycloak.models.dblock.DBLockProviderFactory create mode 100644 server-spi/src/main/java/org/keycloak/models/dblock/DBLockProvider.java create mode 100644 server-spi/src/main/java/org/keycloak/models/dblock/DBLockProviderFactory.java create mode 100644 server-spi/src/main/java/org/keycloak/models/dblock/DBLockSpi.java create mode 100644 services/src/main/java/org/keycloak/services/managers/DBLockManager.java create mode 100644 testsuite/integration/src/test/java/org/keycloak/testsuite/model/DBLockTest.java diff --git a/docbook/auth-server-docs/reference/en/en-US/modules/clustering.xml b/docbook/auth-server-docs/reference/en/en-US/modules/clustering.xml index 850326eb44..cb36fc4c09 100755 --- a/docbook/auth-server-docs/reference/en/en-US/modules/clustering.xml +++ b/docbook/auth-server-docs/reference/en/en-US/modules/clustering.xml @@ -57,6 +57,29 @@ database. This can be a relational database or Mongo. To make sure your database doesn't become a single point of failure you may also want to deploy your database to a cluster. +
+ DB lock + Note that Keycloak supports concurrent startup by more cluster nodes at the same. This is ensured by DB lock, which prevents that some + startup actions (migrating database from previous version, importing realms at startup, initial bootstrap of admin user) are always executed just by one + cluster node at a time and other cluster nodes need to wait until the current node finishes startup actions and release the DB lock. + + + By default, the maximum timeout for lock is 900 seconds, so in case that second node is not able to acquire the lock within 900 seconds, it fails to start. + The lock checking is done every 2 seconds by default. Typically you won't need to increase/decrease the default value, but just in case + it's possible to configure it in standalone/configuration/keycloak-server.json: + + + + or similarly if you're using Mongo (just by replace jpa with mongo) + +
diff --git a/model/jpa/src/main/java/org/keycloak/connections/jpa/DefaultJpaConnectionProviderFactory.java b/model/jpa/src/main/java/org/keycloak/connections/jpa/DefaultJpaConnectionProviderFactory.java index 144df70ec5..4d931488c2 100755 --- a/model/jpa/src/main/java/org/keycloak/connections/jpa/DefaultJpaConnectionProviderFactory.java +++ b/model/jpa/src/main/java/org/keycloak/connections/jpa/DefaultJpaConnectionProviderFactory.java @@ -126,7 +126,7 @@ public class DefaultJpaConnectionProviderFactory implements JpaConnectionProvide properties.put("hibernate.dialect", driverDialect); } - String schema = config.get("schema"); + String schema = getSchema(); if (schema != null) { properties.put(JpaUtils.HIBERNATE_DEFAULT_SCHEMA, schema); } @@ -167,7 +167,7 @@ public class DefaultJpaConnectionProviderFactory implements JpaConnectionProvide } if (currentVersion == null || !JpaUpdaterProvider.LAST_VERSION.equals(currentVersion)) { - updater.update(session, connection, schema); + updater.update(connection, schema); } else { logger.debug("Database is up to date"); } @@ -212,7 +212,8 @@ public class DefaultJpaConnectionProviderFactory implements JpaConnectionProvide } } - private Connection getConnection() { + @Override + public Connection getConnection() { try { String dataSourceLookup = config.get("dataSource"); if (dataSourceLookup != null) { @@ -226,6 +227,11 @@ public class DefaultJpaConnectionProviderFactory implements JpaConnectionProvide throw new RuntimeException("Failed to connect to database", e); } } + + @Override + public String getSchema() { + return config.get("schema"); + } @Override public Map getOperationalInfo() { diff --git a/model/jpa/src/main/java/org/keycloak/connections/jpa/JpaConnectionProviderFactory.java b/model/jpa/src/main/java/org/keycloak/connections/jpa/JpaConnectionProviderFactory.java index edede60965..8d687bcef9 100644 --- a/model/jpa/src/main/java/org/keycloak/connections/jpa/JpaConnectionProviderFactory.java +++ b/model/jpa/src/main/java/org/keycloak/connections/jpa/JpaConnectionProviderFactory.java @@ -17,6 +17,8 @@ package org.keycloak.connections.jpa; +import java.sql.Connection; + import org.keycloak.provider.ProviderFactory; /** @@ -24,4 +26,9 @@ import org.keycloak.provider.ProviderFactory; */ public interface JpaConnectionProviderFactory extends ProviderFactory { + // Caller is responsible for closing connection + Connection getConnection(); + + String getSchema(); + } diff --git a/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/JpaUpdaterProvider.java b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/JpaUpdaterProvider.java index 2e2c5d1f29..0e0dea2e7e 100755 --- a/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/JpaUpdaterProvider.java +++ b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/JpaUpdaterProvider.java @@ -17,7 +17,6 @@ package org.keycloak.connections.jpa.updater; -import org.keycloak.models.KeycloakSession; import org.keycloak.provider.Provider; import java.sql.Connection; @@ -33,7 +32,7 @@ public interface JpaUpdaterProvider extends Provider { public String getCurrentVersionSql(String defaultSchema); - public void update(KeycloakSession session, Connection connection, String defaultSchema); + public void update(Connection connection, String defaultSchema); public void validate(Connection connection, String defaultSchema); diff --git a/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/LiquibaseJpaUpdaterProvider.java b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/LiquibaseJpaUpdaterProvider.java index 80bb46710a..61075c7d3c 100755 --- a/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/LiquibaseJpaUpdaterProvider.java +++ b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/LiquibaseJpaUpdaterProvider.java @@ -20,18 +20,10 @@ package org.keycloak.connections.jpa.updater.liquibase; import liquibase.Contexts; import liquibase.Liquibase; import liquibase.changelog.ChangeSet; -import liquibase.changelog.DatabaseChangeLog; import liquibase.changelog.RanChangeSet; -import liquibase.database.Database; -import liquibase.database.DatabaseFactory; -import liquibase.database.core.DB2Database; -import liquibase.database.jvm.JdbcConnection; -import liquibase.logging.LogFactory; -import liquibase.logging.LogLevel; -import liquibase.resource.ClassLoaderResourceAccessor; -import liquibase.servicelocator.ServiceLocator; import org.jboss.logging.Logger; import org.keycloak.connections.jpa.updater.JpaUpdaterProvider; +import org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProvider; import org.keycloak.models.KeycloakSession; import java.sql.Connection; @@ -46,8 +38,14 @@ public class LiquibaseJpaUpdaterProvider implements JpaUpdaterProvider { private static final Logger logger = Logger.getLogger(LiquibaseJpaUpdaterProvider.class); - private static final String CHANGELOG = "META-INF/jpa-changelog-master.xml"; - private static final String DB2_CHANGELOG = "META-INF/db2-jpa-changelog-master.xml"; + public static final String CHANGELOG = "META-INF/jpa-changelog-master.xml"; + public static final String DB2_CHANGELOG = "META-INF/db2-jpa-changelog-master.xml"; + + private final KeycloakSession session; + + public LiquibaseJpaUpdaterProvider(KeycloakSession session) { + this.session = session; + } @Override public String getCurrentVersionSql(String defaultSchema) { @@ -55,7 +53,7 @@ public class LiquibaseJpaUpdaterProvider implements JpaUpdaterProvider { } @Override - public void update(KeycloakSession session, Connection connection, String defaultSchema) { + public void update(Connection connection, String defaultSchema) { logger.debug("Starting database update"); // Need ThreadLocal as liquibase doesn't seem to have API to inject custom objects into tasks @@ -108,145 +106,14 @@ public class LiquibaseJpaUpdaterProvider implements JpaUpdaterProvider { } private Liquibase getLiquibase(Connection connection, String defaultSchema) throws Exception { - ServiceLocator sl = ServiceLocator.getInstance(); - - if (!System.getProperties().containsKey("liquibase.scan.packages")) { - if (sl.getPackages().remove("liquibase.core")) { - sl.addPackageToScan("liquibase.core.xml"); - } - - if (sl.getPackages().remove("liquibase.parser")) { - sl.addPackageToScan("liquibase.parser.core.xml"); - } - - if (sl.getPackages().remove("liquibase.serializer")) { - sl.addPackageToScan("liquibase.serializer.core.xml"); - } - - sl.getPackages().remove("liquibase.ext"); - sl.getPackages().remove("liquibase.sdk"); - } - - LogFactory.setInstance(new LogWrapper()); - - // Adding PostgresPlus support to liquibase - DatabaseFactory.getInstance().register(new PostgresPlusDatabase()); - - Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection)); - if (defaultSchema != null) { - database.setDefaultSchemaName(defaultSchema); - } - - String changelog = (database instanceof DB2Database) ? DB2_CHANGELOG : CHANGELOG; - logger.debugf("Using changelog file: %s", changelog); - return new Liquibase(changelog, new ClassLoaderResourceAccessor(getClass().getClassLoader()), database); + LiquibaseConnectionProvider liquibaseProvider = session.getProvider(LiquibaseConnectionProvider.class); + return liquibaseProvider.getLiquibase(connection, defaultSchema); } @Override public void close() { } - private static class LogWrapper extends LogFactory { - - private liquibase.logging.Logger logger = new liquibase.logging.Logger() { - @Override - public void setName(String name) { - } - - @Override - public void setLogLevel(String level) { - } - - @Override - public void setLogLevel(LogLevel level) { - } - - @Override - public void setLogLevel(String logLevel, String logFile) { - } - - @Override - public void severe(String message) { - LiquibaseJpaUpdaterProvider.logger.error(message); - } - - @Override - public void severe(String message, Throwable e) { - LiquibaseJpaUpdaterProvider.logger.error(message, e); - } - - @Override - public void warning(String message) { - // Ignore this warning as cascaded drops doesn't work anyway with all DBs, which we need to support - if ("Database does not support drop with cascade".equals(message)) { - LiquibaseJpaUpdaterProvider.logger.debug(message); - } else { - LiquibaseJpaUpdaterProvider.logger.warn(message); - } - } - - @Override - public void warning(String message, Throwable e) { - LiquibaseJpaUpdaterProvider.logger.warn(message, e); - } - - @Override - public void info(String message) { - LiquibaseJpaUpdaterProvider.logger.debug(message); - } - - @Override - public void info(String message, Throwable e) { - LiquibaseJpaUpdaterProvider.logger.debug(message, e); - } - - @Override - public void debug(String message) { - LiquibaseJpaUpdaterProvider.logger.trace(message); - } - - @Override - public LogLevel getLogLevel() { - if (LiquibaseJpaUpdaterProvider.logger.isTraceEnabled()) { - return LogLevel.DEBUG; - } else if (LiquibaseJpaUpdaterProvider.logger.isDebugEnabled()) { - return LogLevel.INFO; - } else { - return LogLevel.WARNING; - } - } - - @Override - public void debug(String message, Throwable e) { - LiquibaseJpaUpdaterProvider.logger.trace(message, e); - } - - @Override - public void setChangeLog(DatabaseChangeLog databaseChangeLog) { - } - - @Override - public void setChangeSet(ChangeSet changeSet) { - } - - @Override - public int getPriority() { - return 0; - } - }; - - @Override - public liquibase.logging.Logger getLog(String name) { - return logger; - } - - @Override - public liquibase.logging.Logger getLog() { - return logger; - } - - } - public static String getTable(String table, String defaultSchema) { return defaultSchema != null ? defaultSchema + "." + table : table; } diff --git a/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/LiquibaseJpaUpdaterProviderFactory.java b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/LiquibaseJpaUpdaterProviderFactory.java index 28982b9333..26eac9a646 100755 --- a/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/LiquibaseJpaUpdaterProviderFactory.java +++ b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/LiquibaseJpaUpdaterProviderFactory.java @@ -30,7 +30,7 @@ public class LiquibaseJpaUpdaterProviderFactory implements JpaUpdaterProviderFac @Override public JpaUpdaterProvider create(KeycloakSession session) { - return new LiquibaseJpaUpdaterProvider(); + return new LiquibaseJpaUpdaterProvider(session); } @Override diff --git a/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/conn/DefaultLiquibaseConnectionProvider.java b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/conn/DefaultLiquibaseConnectionProvider.java new file mode 100644 index 0000000000..d0c686a0fe --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/conn/DefaultLiquibaseConnectionProvider.java @@ -0,0 +1,235 @@ +/* + * Copyright 2016 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.conn; + +import java.sql.Connection; + +import liquibase.Liquibase; +import liquibase.changelog.ChangeSet; +import liquibase.changelog.DatabaseChangeLog; +import liquibase.database.Database; +import liquibase.database.DatabaseFactory; +import liquibase.database.core.DB2Database; +import liquibase.database.jvm.JdbcConnection; +import liquibase.exception.LiquibaseException; +import liquibase.lockservice.LockService; +import liquibase.lockservice.LockServiceFactory; +import liquibase.logging.LogFactory; +import liquibase.logging.LogLevel; +import liquibase.resource.ClassLoaderResourceAccessor; +import liquibase.servicelocator.ServiceLocator; +import liquibase.sqlgenerator.SqlGeneratorFactory; +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.connections.jpa.updater.liquibase.LiquibaseJpaUpdaterProvider; +import org.keycloak.connections.jpa.updater.liquibase.PostgresPlusDatabase; +import org.keycloak.connections.jpa.updater.liquibase.lock.CustomInsertLockRecordGenerator; +import org.keycloak.connections.jpa.updater.liquibase.lock.CustomLockService; +import org.keycloak.connections.jpa.updater.liquibase.lock.DummyLockService; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +/** + * @author Marek Posolda + */ +public class DefaultLiquibaseConnectionProvider implements LiquibaseConnectionProviderFactory, LiquibaseConnectionProvider { + + private static final Logger logger = Logger.getLogger(DefaultLiquibaseConnectionProvider.class); + + private volatile boolean initialized = false; + + @Override + public LiquibaseConnectionProvider create(KeycloakSession session) { + if (!initialized) { + synchronized (this) { + if (!initialized) { + baseLiquibaseInitialization(); + initialized = true; + } + } + } + return this; + } + + protected void baseLiquibaseInitialization() { + ServiceLocator sl = ServiceLocator.getInstance(); + + if (!System.getProperties().containsKey("liquibase.scan.packages")) { + if (sl.getPackages().remove("liquibase.core")) { + sl.addPackageToScan("liquibase.core.xml"); + } + + if (sl.getPackages().remove("liquibase.parser")) { + sl.addPackageToScan("liquibase.parser.core.xml"); + } + + if (sl.getPackages().remove("liquibase.serializer")) { + sl.addPackageToScan("liquibase.serializer.core.xml"); + } + + sl.getPackages().remove("liquibase.ext"); + sl.getPackages().remove("liquibase.sdk"); + } + + LogFactory.setInstance(new LogWrapper()); + + // Adding PostgresPlus support to liquibase + DatabaseFactory.getInstance().register(new PostgresPlusDatabase()); + + // Change command for creating lock and drop DELETE lock record from it + SqlGeneratorFactory.getInstance().register(new CustomInsertLockRecordGenerator()); + + // We wrap liquibase update in CustomLockService provided by DBLockProvider. No need to lock inside liquibase itself. + LockServiceFactory.getInstance().register(new DummyLockService()); + } + + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + } + + @Override + public String getId() { + return "default"; + } + + @Override + public Liquibase getLiquibase(Connection connection, String defaultSchema) throws LiquibaseException { + Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection)); + if (defaultSchema != null) { + database.setDefaultSchemaName(defaultSchema); + } + + String changelog = (database instanceof DB2Database) ? LiquibaseJpaUpdaterProvider.DB2_CHANGELOG : LiquibaseJpaUpdaterProvider.CHANGELOG; + logger.debugf("Using changelog file: %s", changelog); + return new Liquibase(changelog, new ClassLoaderResourceAccessor(getClass().getClassLoader()), database); + } + + private static class LogWrapper extends LogFactory { + + private liquibase.logging.Logger logger = new liquibase.logging.Logger() { + @Override + public void setName(String name) { + } + + @Override + public void setLogLevel(String level) { + } + + @Override + public void setLogLevel(LogLevel level) { + } + + @Override + public void setLogLevel(String logLevel, String logFile) { + } + + @Override + public void severe(String message) { + DefaultLiquibaseConnectionProvider.logger.error(message); + } + + @Override + public void severe(String message, Throwable e) { + DefaultLiquibaseConnectionProvider.logger.error(message, e); + } + + @Override + public void warning(String message) { + // Ignore this warning as cascaded drops doesn't work anyway with all DBs, which we need to support + if ("Database does not support drop with cascade".equals(message)) { + DefaultLiquibaseConnectionProvider.logger.debug(message); + } else { + DefaultLiquibaseConnectionProvider.logger.warn(message); + } + } + + @Override + public void warning(String message, Throwable e) { + DefaultLiquibaseConnectionProvider.logger.warn(message, e); + } + + @Override + public void info(String message) { + DefaultLiquibaseConnectionProvider.logger.debug(message); + } + + @Override + public void info(String message, Throwable e) { + DefaultLiquibaseConnectionProvider.logger.debug(message, e); + } + + @Override + public void debug(String message) { + if (DefaultLiquibaseConnectionProvider.logger.isTraceEnabled()) { + DefaultLiquibaseConnectionProvider.logger.trace(message); + } + } + + @Override + public LogLevel getLogLevel() { + if (DefaultLiquibaseConnectionProvider.logger.isTraceEnabled()) { + return LogLevel.DEBUG; + } else if (DefaultLiquibaseConnectionProvider.logger.isDebugEnabled()) { + return LogLevel.INFO; + } else { + return LogLevel.WARNING; + } + } + + @Override + public void debug(String message, Throwable e) { + DefaultLiquibaseConnectionProvider.logger.trace(message, e); + } + + @Override + public void setChangeLog(DatabaseChangeLog databaseChangeLog) { + } + + @Override + public void setChangeSet(ChangeSet changeSet) { + } + + @Override + public int getPriority() { + return 0; + } + }; + + @Override + public liquibase.logging.Logger getLog(String name) { + return logger; + } + + @Override + public liquibase.logging.Logger getLog() { + return logger; + } + + } +} diff --git a/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/conn/LiquibaseConnectionProvider.java b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/conn/LiquibaseConnectionProvider.java new file mode 100644 index 0000000000..5aa81cc84e --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/conn/LiquibaseConnectionProvider.java @@ -0,0 +1,33 @@ +/* + * Copyright 2016 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.conn; + +import java.sql.Connection; + +import liquibase.Liquibase; +import liquibase.exception.LiquibaseException; +import org.keycloak.provider.Provider; + +/** + * @author Marek Posolda + */ +public interface LiquibaseConnectionProvider extends Provider { + + Liquibase getLiquibase(Connection connection, String defaultSchema) throws LiquibaseException; + +} diff --git a/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/conn/LiquibaseConnectionProviderFactory.java b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/conn/LiquibaseConnectionProviderFactory.java new file mode 100644 index 0000000000..7dfe5f9d55 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/conn/LiquibaseConnectionProviderFactory.java @@ -0,0 +1,26 @@ +/* + * Copyright 2016 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.conn; + +import org.keycloak.provider.ProviderFactory; + +/** + * @author Marek Posolda + */ +public interface LiquibaseConnectionProviderFactory extends ProviderFactory { +} diff --git a/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/conn/LiquibaseConnectionSpi.java b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/conn/LiquibaseConnectionSpi.java new file mode 100644 index 0000000000..7aeda323b3 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/conn/LiquibaseConnectionSpi.java @@ -0,0 +1,48 @@ +/* + * Copyright 2016 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.conn; + +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +/** + * @author Marek Posolda + */ +public class LiquibaseConnectionSpi implements Spi { + + @Override + public boolean isInternal() { + return true; + } + + @Override + public String getName() { + return "connectionsLiquibase"; + } + + @Override + public Class getProviderClass() { + return LiquibaseConnectionProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return LiquibaseConnectionProviderFactory.class; + } +} diff --git a/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/CustomInsertLockRecordGenerator.java b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/CustomInsertLockRecordGenerator.java new file mode 100644 index 0000000000..3b1e2f9571 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/CustomInsertLockRecordGenerator.java @@ -0,0 +1,64 @@ +/* + * Copyright 2016 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.lock; + +import java.util.ArrayList; +import java.util.List; + +import liquibase.database.Database; +import liquibase.exception.ValidationErrors; +import liquibase.sql.Sql; +import liquibase.sqlgenerator.SqlGeneratorChain; +import liquibase.sqlgenerator.core.AbstractSqlGenerator; +import liquibase.statement.core.DeleteStatement; +import liquibase.statement.core.InitializeDatabaseChangeLogLockTableStatement; + +/** + * We need to remove DELETE SQL command, which liquibase adds by default when inserting record to table lock. This is causing buggy behaviour + * + * @author Marek Posolda + */ +public class CustomInsertLockRecordGenerator extends AbstractSqlGenerator { + + @Override + public int getPriority() { + return super.getPriority() + 1; // Ensure bigger priority than InitializeDatabaseChangeLogLockTableGenerator + } + + @Override + public ValidationErrors validate(InitializeDatabaseChangeLogLockTableStatement initializeDatabaseChangeLogLockTableStatement, Database database, SqlGeneratorChain sqlGeneratorChain) { + return new ValidationErrors(); + } + + @Override + public Sql[] generateSql(InitializeDatabaseChangeLogLockTableStatement statement, Database database, SqlGeneratorChain sqlGeneratorChain) { + // Generated by InitializeDatabaseChangeLogLockTableGenerator + Sql[] sqls = sqlGeneratorChain.generateSql(statement, database); + + // Removing delete statement + List result = new ArrayList<>(); + for (Sql sql : sqls) { + String sqlCommand = sql.toSql(); + if (!sqlCommand.toUpperCase().contains("DELETE")) { + result.add(sql); + } + } + + return result.toArray(new Sql[result.size()]); + } +} diff --git a/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/CustomLockService.java b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/CustomLockService.java new file mode 100644 index 0000000000..0c246ca9a3 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/CustomLockService.java @@ -0,0 +1,175 @@ +/* + * Copyright 2016 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.lock; + +import java.lang.reflect.Field; +import java.text.DateFormat; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; + +import liquibase.database.Database; +import liquibase.database.core.DerbyDatabase; +import liquibase.exception.DatabaseException; +import liquibase.exception.LockException; +import liquibase.executor.Executor; +import liquibase.executor.ExecutorService; +import liquibase.lockservice.DatabaseChangeLogLock; +import liquibase.lockservice.StandardLockService; +import liquibase.logging.LogFactory; +import liquibase.sql.visitor.AbstractSqlVisitor; +import liquibase.sql.visitor.SqlVisitor; +import liquibase.statement.core.CreateDatabaseChangeLogLockTableStatement; +import liquibase.statement.core.DropTableStatement; +import liquibase.statement.core.InitializeDatabaseChangeLogLockTableStatement; +import liquibase.statement.core.RawSqlStatement; +import org.jboss.logging.Logger; +import org.keycloak.common.util.Time; +import org.keycloak.common.util.reflections.Reflections; + +/** + * Liquibase lock service, which has some bugfixes and assumes timeouts to be configured in milliseconds + * + * @author Marek Posolda + */ +public class CustomLockService extends StandardLockService { + + private static final Logger log = Logger.getLogger(CustomLockService.class); + + private long changeLogLocRecheckTimeMillis = -1; + + @Override + public void setChangeLogLockRecheckTime(long changeLogLocRecheckTime) { + super.setChangeLogLockRecheckTime(changeLogLocRecheckTime); + this.changeLogLocRecheckTimeMillis = changeLogLocRecheckTime; + } + + // Bug in StandardLockService.getChangeLogLockRecheckTime() + @Override + public Long getChangeLogLockRecheckTime() { + if (changeLogLocRecheckTimeMillis == -1) { + return super.getChangeLogLockRecheckTime(); + } else { + return changeLogLocRecheckTimeMillis; + } + } + + @Override + public void init() throws DatabaseException { + boolean createdTable = false; + Executor executor = ExecutorService.getInstance().getExecutor(database); + + if (!hasDatabaseChangeLogLockTable()) { + + try { + if (log.isTraceEnabled()) { + log.trace("Create Database Lock Table"); + } + executor.execute(new CreateDatabaseChangeLogLockTableStatement()); + database.commit(); + } catch (DatabaseException de) { + log.warn("Failed to create lock table. Maybe other transaction created in the meantime. Retrying..."); + if (log.isDebugEnabled()) { + log.debug(de.getMessage(), de); //Log details at debug level + } + database.rollback(); + throw new LockRetryException(de); + } + + log.debugf("Created database lock table with name: %s", database.escapeTableName(database.getLiquibaseCatalogName(), database.getLiquibaseSchemaName(), database.getDatabaseChangeLogLockTableName())); + + try { + Field field = Reflections.findDeclaredField(StandardLockService.class, "hasDatabaseChangeLogLockTable"); + Reflections.setAccessible(field); + field.set(CustomLockService.this, true); + } catch (IllegalAccessException iae) { + throw new RuntimeException(iae); + } + + createdTable = true; + } + + + if (!isDatabaseChangeLogLockTableInitialized(createdTable)) { + try { + if (log.isTraceEnabled()) { + log.trace("Initialize Database Lock Table"); + } + executor.execute(new InitializeDatabaseChangeLogLockTableStatement()); + database.commit(); + + } catch (DatabaseException de) { + log.warn("Failed to insert first record to the lock table. Maybe other transaction inserted in the meantime. Retrying..."); + if (log.isDebugEnabled()) { + log.debug(de.getMessage(), de); // Log details at debug level + } + database.rollback(); + throw new LockRetryException(de); + } + + log.debug("Initialized record in the database lock table"); + } + + + // Keycloak doesn't support Derby, but keep it for sure... + if (executor.updatesDatabase() && database instanceof DerbyDatabase && ((DerbyDatabase) database).supportsBooleanDataType()) { //check if the changelog table is of an old smallint vs. boolean format + String lockTable = database.escapeTableName(database.getLiquibaseCatalogName(), database.getLiquibaseSchemaName(), database.getDatabaseChangeLogLockTableName()); + Object obj = executor.queryForObject(new RawSqlStatement("select min(locked) as test from " + lockTable + " fetch first row only"), Object.class); + if (!(obj instanceof Boolean)) { //wrong type, need to recreate table + executor.execute(new DropTableStatement(database.getLiquibaseCatalogName(), database.getLiquibaseSchemaName(), database.getDatabaseChangeLogLockTableName(), false)); + executor.execute(new CreateDatabaseChangeLogLockTableStatement()); + executor.execute(new InitializeDatabaseChangeLogLockTableStatement()); + } + } + + } + + @Override + public void waitForLock() throws LockException { + boolean locked = false; + long startTime = Time.toMillis(Time.currentTime()); + long timeToGiveUp = startTime + (getChangeLogLockWaitTime()); + + while (!locked && Time.toMillis(Time.currentTime()) < timeToGiveUp) { + locked = acquireLock(); + if (!locked) { + int remainingTime = ((int)(timeToGiveUp / 1000)) - Time.currentTime(); + log.debugf("Waiting for changelog lock... Remaining time: %d seconds", remainingTime); + try { + Thread.sleep(getChangeLogLockRecheckTime()); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + } + + if (!locked) { + DatabaseChangeLogLock[] locks = listLocks(); + String lockedBy; + if (locks.length > 0) { + DatabaseChangeLogLock lock = locks[0]; + lockedBy = lock.getLockedBy() + " since " + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(lock.getLockGranted()); + } else { + lockedBy = "UNKNOWN"; + } + throw new LockException("Could not acquire change log lock. Currently locked by " + lockedBy); + } + } + + +} diff --git a/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/DummyLockService.java b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/DummyLockService.java new file mode 100644 index 0000000000..61ef19fdfa --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/DummyLockService.java @@ -0,0 +1,38 @@ +/* + * Copyright 2016 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.lock; + +import liquibase.exception.LockException; +import liquibase.lockservice.StandardLockService; + +/** + * Dummy lock service injected to Liquibase. Doesn't need to do anything as we already have a lock when Liquibase update is called. + * + * @author Marek Posolda + */ +public class DummyLockService extends StandardLockService { + + @Override + public void waitForLock() throws LockException { + } + + @Override + public void releaseLock() throws LockException { + } + +} diff --git a/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/LiquibaseDBLockProvider.java b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/LiquibaseDBLockProvider.java new file mode 100644 index 0000000000..8769053102 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/LiquibaseDBLockProvider.java @@ -0,0 +1,159 @@ +/* + * Copyright 2016 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.lock; + +import java.sql.Connection; +import java.sql.SQLException; + +import liquibase.Liquibase; +import liquibase.exception.DatabaseException; +import liquibase.exception.LiquibaseException; +import liquibase.exception.LockException; +import liquibase.lockservice.LockService; +import org.jboss.logging.Logger; +import org.keycloak.connections.jpa.JpaConnectionProvider; +import org.keycloak.connections.jpa.JpaConnectionProviderFactory; +import org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.dblock.DBLockProvider; + +/** + * @author Marek Posolda + */ +public class LiquibaseDBLockProvider implements DBLockProvider { + + private static final Logger logger = Logger.getLogger(LiquibaseDBLockProvider.class); + + // 3 should be sufficient (Potentially one failure for createTable and one for insert record) + private int DEFAULT_MAX_ATTEMPTS = 3; + + + private final LiquibaseDBLockProviderFactory factory; + private final KeycloakSession session; + + private LockService lockService; + private Connection dbConnection; + + private int maxAttempts = DEFAULT_MAX_ATTEMPTS; + + public LiquibaseDBLockProvider(LiquibaseDBLockProviderFactory factory, KeycloakSession session) { + this.factory = factory; + this.session = session; + init(); + } + + private void init() { + LiquibaseConnectionProvider liquibaseProvider = session.getProvider(LiquibaseConnectionProvider.class); + JpaConnectionProviderFactory jpaProviderFactory = (JpaConnectionProviderFactory) session.getKeycloakSessionFactory().getProviderFactory(JpaConnectionProvider.class); + + this.dbConnection = jpaProviderFactory.getConnection(); + String defaultSchema = jpaProviderFactory.getSchema(); + + try { + Liquibase liquibase = liquibaseProvider.getLiquibase(dbConnection, defaultSchema); + + this.lockService = new CustomLockService(); + lockService.setChangeLogLockWaitTime(factory.getLockWaitTimeoutMillis()); + lockService.setChangeLogLockRecheckTime(factory.getLockRecheckTimeMillis()); + lockService.setDatabase(liquibase.getDatabase()); + } catch (LiquibaseException exception) { + safeRollbackConnection(); + safeCloseConnection(); + throw new IllegalStateException(exception); + } + } + + // Assumed transaction was rolled-back and we want to start with new DB connection + private void restart() { + safeCloseConnection(); + this.dbConnection = null; + this.lockService = null; + init(); + } + + + @Override + public void waitForLock() { + while (maxAttempts > 0) { + try { + lockService.waitForLock(); + this.maxAttempts = DEFAULT_MAX_ATTEMPTS; + return; + } catch (LockException le) { + if (le.getCause() != null && le.getCause() instanceof LockRetryException) { + // Indicates we should try to acquire lock again in different transaction + restart(); + maxAttempts--; + } else { + throw new IllegalStateException("Failed to retrieve lock", le); + + // TODO: Possibility to forcefully retrieve lock after timeout instead of just give-up? + } + } + } + } + + + @Override + public void releaseLock() { + try { + lockService.releaseLock(); + } catch (LockException e) { + logger.error("Could not release lock", e); + } + lockService.reset(); + } + + @Override + public void destroyLockInfo() { + try { + this.lockService.destroy(); + dbConnection.commit(); + logger.debug("Destroyed lock table"); + } catch (DatabaseException | SQLException de) { + logger.error("Failed to destroy lock table"); + safeRollbackConnection(); + } + } + + @Override + public void close() { + safeCloseConnection(); + } + + private void safeRollbackConnection() { + if (dbConnection != null) { + try { + this.dbConnection.rollback(); + } catch (SQLException se) { + logger.warn("Failed to rollback connection after error", se); + } + } + } + + private void safeCloseConnection() { + // Close after creating EntityManagerFactory to prevent in-mem databases from closing + if (dbConnection != null) { + try { + dbConnection.close(); + } catch (SQLException e) { + logger.warn("Failed to close connection", e); + } + } + } +} diff --git a/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/LiquibaseDBLockProviderFactory.java b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/LiquibaseDBLockProviderFactory.java new file mode 100644 index 0000000000..5ce8be3f47 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/LiquibaseDBLockProviderFactory.java @@ -0,0 +1,79 @@ +/* + * Copyright 2016 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.lock; + +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.common.util.Time; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.dblock.DBLockProviderFactory; + +/** + * @author Marek Posolda + */ +public class LiquibaseDBLockProviderFactory implements DBLockProviderFactory { + + private static final Logger logger = Logger.getLogger(LiquibaseDBLockProviderFactory.class); + + private long lockRecheckTimeMillis; + private long lockWaitTimeoutMillis; + + protected long getLockRecheckTimeMillis() { + return lockRecheckTimeMillis; + } + + protected long getLockWaitTimeoutMillis() { + return lockWaitTimeoutMillis; + } + + @Override + public void init(Config.Scope config) { + int lockRecheckTime = config.getInt("lockRecheckTime", 2); + int lockWaitTimeout = config.getInt("lockWaitTimeout", 900); + this.lockRecheckTimeMillis = Time.toMillis(lockRecheckTime); + this.lockWaitTimeoutMillis = Time.toMillis(lockWaitTimeout); + logger.debugf("Liquibase lock provider configured with lockWaitTime: %d seconds, lockRecheckTime: %d seconds", lockWaitTimeout, lockRecheckTime); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public LiquibaseDBLockProvider create(KeycloakSession session) { + return new LiquibaseDBLockProvider(this, session); + } + + @Override + public void setTimeouts(long lockRecheckTimeMillis, long lockWaitTimeoutMillis) { + this.lockRecheckTimeMillis = lockRecheckTimeMillis; + this.lockWaitTimeoutMillis = lockWaitTimeoutMillis; + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return "jpa"; + } +} diff --git a/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/LockRetryException.java b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/LockRetryException.java new file mode 100644 index 0000000000..e86e2b5dda --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/lock/LockRetryException.java @@ -0,0 +1,39 @@ +/* + * Copyright 2016 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.lock; + +/** + * Indicates that retrieve lock wasn't successful, but it worth to retry it in different transaction (For example if we were trying to create LOCK table, but other transaction + * created the table in the meantime etc) + * + * @author Marek Posolda + */ +public class LockRetryException extends RuntimeException { + + public LockRetryException(String message) { + super(message); + } + + public LockRetryException(Throwable cause) { + super(cause); + } + + public LockRetryException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/model/jpa/src/main/resources/META-INF/db2-jpa-changelog-master.xml b/model/jpa/src/main/resources/META-INF/db2-jpa-changelog-master.xml index ae4592d575..2d6b669676 100644 --- a/model/jpa/src/main/resources/META-INF/db2-jpa-changelog-master.xml +++ b/model/jpa/src/main/resources/META-INF/db2-jpa-changelog-master.xml @@ -30,4 +30,5 @@ + diff --git a/model/jpa/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory b/model/jpa/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory new file mode 100644 index 0000000000..f7e18f0ce0 --- /dev/null +++ b/model/jpa/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory @@ -0,0 +1,18 @@ +# +# Copyright 2016 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. +# + +org.keycloak.connections.jpa.updater.liquibase.conn.DefaultLiquibaseConnectionProvider \ No newline at end of file diff --git a/model/jpa/src/main/resources/META-INF/services/org.keycloak.models.dblock.DBLockProviderFactory b/model/jpa/src/main/resources/META-INF/services/org.keycloak.models.dblock.DBLockProviderFactory new file mode 100644 index 0000000000..2b2ac0234b --- /dev/null +++ b/model/jpa/src/main/resources/META-INF/services/org.keycloak.models.dblock.DBLockProviderFactory @@ -0,0 +1,18 @@ +# +# Copyright 2016 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. +# + +org.keycloak.connections.jpa.updater.liquibase.lock.LiquibaseDBLockProviderFactory \ No newline at end of file diff --git a/model/jpa/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/model/jpa/src/main/resources/META-INF/services/org.keycloak.provider.Spi index 16b42ba3ee..94c651287a 100644 --- a/model/jpa/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ b/model/jpa/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -16,4 +16,5 @@ # org.keycloak.connections.jpa.JpaConnectionSpi -org.keycloak.connections.jpa.updater.JpaUpdaterSpi \ No newline at end of file +org.keycloak.connections.jpa.updater.JpaUpdaterSpi +org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionSpi \ No newline at end of file diff --git a/model/mongo/src/main/java/org/keycloak/connections/mongo/DefaultMongoConnectionFactoryProvider.java b/model/mongo/src/main/java/org/keycloak/connections/mongo/DefaultMongoConnectionFactoryProvider.java index 744d14e02e..74367c46d9 100755 --- a/model/mongo/src/main/java/org/keycloak/connections/mongo/DefaultMongoConnectionFactoryProvider.java +++ b/model/mongo/src/main/java/org/keycloak/connections/mongo/DefaultMongoConnectionFactoryProvider.java @@ -78,7 +78,13 @@ public class DefaultMongoConnectionFactoryProvider implements MongoConnectionPro private static final Logger logger = Logger.getLogger(DefaultMongoConnectionFactoryProvider.class); - private volatile MongoClient client; + private static final int STATE_BEFORE_INIT = 0; // Even before MongoClient is created + private static final int STATE_BEFORE_UPDATE = 1; // Mongo client was created, but DB is not yet updated to last version + private static final int STATE_AFTER_UPDATE = 2; // Mongo client was created and DB updated. DB is fully initialized now + + private volatile int state = STATE_BEFORE_INIT; + + private MongoClient client; private MongoStore mongoStore; private DB db; @@ -86,15 +92,6 @@ public class DefaultMongoConnectionFactoryProvider implements MongoConnectionPro private Map operationalInfo; - @Override - public MongoConnectionProvider create(KeycloakSession session) { - lazyInit(session); - - TransactionMongoStoreInvocationContext invocationContext = new TransactionMongoStoreInvocationContext(mongoStore); - session.getTransaction().enlist(new MongoKeycloakTransaction(invocationContext)); - return new DefaultMongoConnectionProvider(db, mongoStore, invocationContext); - } - @Override public void init(Config.Scope config) { this.config = config; @@ -105,33 +102,22 @@ public class DefaultMongoConnectionFactoryProvider implements MongoConnectionPro } + @Override + public DB getDBBeforeUpdate() { + lazyInitBeforeUpdate(); + return db; + } - private void lazyInit(KeycloakSession session) { - if (client == null) { + private void lazyInitBeforeUpdate() { + if (state == STATE_BEFORE_INIT) { synchronized (this) { - if (client == null) { + if (state == STATE_BEFORE_INIT) { try { this.client = createMongoClient(); - String dbName = config.get("db", "keycloak"); this.db = client.getDB(dbName); - String databaseSchema = config.get("databaseSchema"); - if (databaseSchema != null) { - if (databaseSchema.equals("update")) { - MongoUpdaterProvider mongoUpdater = session.getProvider(MongoUpdaterProvider.class); - - if (mongoUpdater == null) { - throw new RuntimeException("Can't update database: Mongo updater provider not found"); - } - - mongoUpdater.update(session, db); - } else { - throw new RuntimeException("Invalid value for databaseSchema: " + databaseSchema); - } - } - - this.mongoStore = new MongoStoreImpl(db, getManagedEntities()); + state = STATE_BEFORE_UPDATE; } catch (Exception e) { throw new RuntimeException(e); } @@ -140,6 +126,53 @@ public class DefaultMongoConnectionFactoryProvider implements MongoConnectionPro } } + + @Override + public MongoConnectionProvider create(KeycloakSession session) { + lazyInit(session); + + TransactionMongoStoreInvocationContext invocationContext = new TransactionMongoStoreInvocationContext(mongoStore); + session.getTransaction().enlist(new MongoKeycloakTransaction(invocationContext)); + return new DefaultMongoConnectionProvider(db, mongoStore, invocationContext); + } + + private void lazyInit(KeycloakSession session) { + lazyInitBeforeUpdate(); + + if (state == STATE_BEFORE_UPDATE) { + synchronized (this) { + if (state == STATE_BEFORE_UPDATE) { + try { + update(session); + this.mongoStore = new MongoStoreImpl(db, getManagedEntities()); + + state = STATE_AFTER_UPDATE; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + } + } + + private void update(KeycloakSession session) { + String databaseSchema = config.get("databaseSchema"); + if (databaseSchema != null) { + if (databaseSchema.equals("update")) { + MongoUpdaterProvider mongoUpdater = session.getProvider(MongoUpdaterProvider.class); + + if (mongoUpdater == null) { + throw new RuntimeException("Can't update database: Mongo updater provider not found"); + } + + mongoUpdater.update(session, db); + } else { + throw new RuntimeException("Invalid value for databaseSchema: " + databaseSchema); + } + } + } + + private Class[] getManagedEntities() throws ClassNotFoundException { Class[] entityClasses = new Class[entities.length]; for (int i = 0; i < entities.length; i++) { @@ -160,6 +193,7 @@ public class DefaultMongoConnectionFactoryProvider implements MongoConnectionPro return "default"; } + /** * Override this method if you want more possibility to configure Mongo client. It can be also used to inject mongo client * from different source. diff --git a/model/mongo/src/main/java/org/keycloak/connections/mongo/MongoConnectionProvider.java b/model/mongo/src/main/java/org/keycloak/connections/mongo/MongoConnectionProvider.java index 0e9f84a7a9..f13eaf7a4e 100644 --- a/model/mongo/src/main/java/org/keycloak/connections/mongo/MongoConnectionProvider.java +++ b/model/mongo/src/main/java/org/keycloak/connections/mongo/MongoConnectionProvider.java @@ -27,6 +27,9 @@ import org.keycloak.provider.Provider; */ public interface MongoConnectionProvider extends Provider { + /** + * @return Fully updated and initialized DB + */ DB getDB(); MongoStore getMongoStore(); diff --git a/model/mongo/src/main/java/org/keycloak/connections/mongo/MongoConnectionProviderFactory.java b/model/mongo/src/main/java/org/keycloak/connections/mongo/MongoConnectionProviderFactory.java index 2d5d4ca149..42a18533d9 100644 --- a/model/mongo/src/main/java/org/keycloak/connections/mongo/MongoConnectionProviderFactory.java +++ b/model/mongo/src/main/java/org/keycloak/connections/mongo/MongoConnectionProviderFactory.java @@ -17,10 +17,17 @@ package org.keycloak.connections.mongo; +import com.mongodb.DB; import org.keycloak.provider.ProviderFactory; /** * @author Stian Thorgersen */ public interface MongoConnectionProviderFactory extends ProviderFactory { + + /** + * @return DB object, which may not be yet updated to current Keycloak version. Useful just if something needs to be done even before DB update (for example acquire DB lock) + */ + DB getDBBeforeUpdate(); + } diff --git a/model/mongo/src/main/java/org/keycloak/connections/mongo/lock/MongoDBLockProvider.java b/model/mongo/src/main/java/org/keycloak/connections/mongo/lock/MongoDBLockProvider.java new file mode 100644 index 0000000000..e3383fece0 --- /dev/null +++ b/model/mongo/src/main/java/org/keycloak/connections/mongo/lock/MongoDBLockProvider.java @@ -0,0 +1,137 @@ +/* + * Copyright 2016 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.mongo.lock; + +import com.mongodb.BasicDBObject; +import com.mongodb.DB; +import com.mongodb.DBCursor; +import com.mongodb.DBObject; +import com.mongodb.DuplicateKeyException; +import com.mongodb.WriteResult; +import org.jboss.logging.Logger; +import org.keycloak.common.util.HostUtils; +import org.keycloak.common.util.Time; +import org.keycloak.models.dblock.DBLockProvider; + +/** + * @author Marek Posolda + */ +public class MongoDBLockProvider implements DBLockProvider { + + private static final String DB_LOCK_COLLECTION = "dblock"; + private static final Logger logger = Logger.getLogger(MongoDBLockProvider .class); + + private final MongoDBLockProviderFactory factory; + private final DB db; + + public MongoDBLockProvider(MongoDBLockProviderFactory factory, DB db) { + this.factory = factory; + this.db = db; + } + + + @Override + public void waitForLock() { + boolean locked = false; + long startTime = Time.toMillis(Time.currentTime()); + long timeToGiveUp = startTime + (factory.getLockWaitTimeoutMillis()); + + while (!locked && Time.toMillis(Time.currentTime()) < timeToGiveUp) { + locked = acquireLock(); + if (!locked) { + int remainingTime = ((int)(timeToGiveUp / 1000)) - Time.currentTime(); + logger.debugf("Waiting for changelog lock... Remaining time: %d seconds", remainingTime); + try { + Thread.sleep(factory.getLockRecheckTimeMillis()); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + } + + if (!locked) { + DBObject query = new BasicDBObject("_id", 1); + DBCursor cursor = db.getCollection(DB_LOCK_COLLECTION).find(query); + String lockedBy; + if (cursor.hasNext()) { + DBObject dbObj = cursor.next(); + lockedBy = dbObj.get("lockedBy") + " since " + Time.toDate(((int)((long) dbObj.get("lockedSince") / 1000))); + } else { + lockedBy = "UNKNOWN"; + } + throw new IllegalStateException("Could not acquire change log lock. Currently locked by " + lockedBy); + } + } + + + private boolean acquireLock() { + DBObject query = new BasicDBObject("locked", false); + + BasicDBObject update = new BasicDBObject("locked", true); + update.append("_id", 1); + update.append("lockedSince", Time.toMillis(Time.currentTime())); + update.append("lockedBy", HostUtils.getHostName()); // Maybe replace with something better, but doesn't matter for now + + try { + WriteResult wr = db.getCollection(DB_LOCK_COLLECTION).update(query, update, true, false); + if (wr.getN() == 1) { + logger.debugf("Successfully acquired DB lock"); + return true; + } else { + return false; + } + } catch (DuplicateKeyException dke) { + logger.debugf("Failed acquire lock. Reason: %s", dke.getMessage()); + } + + return false; + } + + + @Override + public void releaseLock() { + DBObject query = new BasicDBObject("locked", true); + + BasicDBObject update = new BasicDBObject("locked", false); + update.append("_id", 1); + update.append("lockedBy", null); + update.append("lockedSince", null); + + try { + WriteResult wr = db.getCollection(DB_LOCK_COLLECTION).update(query, update, true, false); + if (wr.getN() > 0) { + logger.debugf("Successfully released DB lock"); + } else { + logger.warnf("Attempt to release DB lock, but nothing was released"); + } + } catch (DuplicateKeyException dke) { + logger.debugf("Failed release lock. Reason: %s", dke.getMessage()); + } + } + + @Override + public void destroyLockInfo() { + db.getCollection(DB_LOCK_COLLECTION).remove(new BasicDBObject()); + logger.debugf("Destroyed lock collection"); + } + + @Override + public void close() { + + } +} diff --git a/model/mongo/src/main/java/org/keycloak/connections/mongo/lock/MongoDBLockProviderFactory.java b/model/mongo/src/main/java/org/keycloak/connections/mongo/lock/MongoDBLockProviderFactory.java new file mode 100644 index 0000000000..7bd6e02181 --- /dev/null +++ b/model/mongo/src/main/java/org/keycloak/connections/mongo/lock/MongoDBLockProviderFactory.java @@ -0,0 +1,84 @@ +/* + * Copyright 2016 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.mongo.lock; + +import com.mongodb.DB; +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.common.util.Time; +import org.keycloak.connections.mongo.MongoConnectionProvider; +import org.keycloak.connections.mongo.MongoConnectionProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.dblock.DBLockProviderFactory; + +/** + * @author Marek Posolda + */ +public class MongoDBLockProviderFactory implements DBLockProviderFactory { + + private static final Logger logger = Logger.getLogger(MongoDBLockProviderFactory.class); + + private long lockRecheckTimeMillis; + private long lockWaitTimeoutMillis; + + protected long getLockRecheckTimeMillis() { + return lockRecheckTimeMillis; + } + + protected long getLockWaitTimeoutMillis() { + return lockWaitTimeoutMillis; + } + + @Override + public void init(Config.Scope config) { + int lockRecheckTime = config.getInt("lockRecheckTime", 2); + int lockWaitTimeout = config.getInt("lockWaitTimeout", 900); + this.lockRecheckTimeMillis = Time.toMillis(lockRecheckTime); + this.lockWaitTimeoutMillis = Time.toMillis(lockWaitTimeout); + logger.debugf("Mongo lock provider configured with lockWaitTime: %d seconds, lockRecheckTime: %d seconds", lockWaitTimeout, lockRecheckTime); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public MongoDBLockProvider create(KeycloakSession session) { + MongoConnectionProviderFactory mongoConnectionFactory = (MongoConnectionProviderFactory) session.getKeycloakSessionFactory().getProviderFactory(MongoConnectionProvider.class); + DB db = mongoConnectionFactory.getDBBeforeUpdate(); + return new MongoDBLockProvider(this, db); + } + + @Override + public void setTimeouts(long lockRecheckTimeMillis, long lockWaitTimeoutMillis) { + this.lockRecheckTimeMillis = lockRecheckTimeMillis; + this.lockWaitTimeoutMillis = lockWaitTimeoutMillis; + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return "mongo"; + } +} diff --git a/model/mongo/src/main/resources/META-INF/services/org.keycloak.models.dblock.DBLockProviderFactory b/model/mongo/src/main/resources/META-INF/services/org.keycloak.models.dblock.DBLockProviderFactory new file mode 100644 index 0000000000..4c6c4aa4c8 --- /dev/null +++ b/model/mongo/src/main/resources/META-INF/services/org.keycloak.models.dblock.DBLockProviderFactory @@ -0,0 +1,35 @@ +# +# Copyright 2016 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. +# + +# +# Copyright 2016 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. +# + +org.keycloak.connections.mongo.lock.MongoDBLockProviderFactory \ No newline at end of file diff --git a/server-spi/src/main/java/org/keycloak/models/dblock/DBLockProvider.java b/server-spi/src/main/java/org/keycloak/models/dblock/DBLockProvider.java new file mode 100644 index 0000000000..b5fb417aca --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/dblock/DBLockProvider.java @@ -0,0 +1,44 @@ +/* + * Copyright 2016 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.dblock; + +import org.keycloak.provider.Provider; + +/** + * Global database lock to ensure that some actions in DB can be done just be one cluster node at a time. + * + * + * @author Marek Posolda + */ +public interface DBLockProvider extends Provider { + + + /** + * Try to retrieve DB lock or wait if retrieve was unsuccessful. Throw exception if lock can't be retrieved within specified timeout (900 seconds by default) + */ + void waitForLock(); + + + void releaseLock(); + + + /** + * Will destroy whole state of DB lock (drop table/collection to track locking). + * */ + void destroyLockInfo(); +} diff --git a/server-spi/src/main/java/org/keycloak/models/dblock/DBLockProviderFactory.java b/server-spi/src/main/java/org/keycloak/models/dblock/DBLockProviderFactory.java new file mode 100644 index 0000000000..747a168126 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/dblock/DBLockProviderFactory.java @@ -0,0 +1,31 @@ +/* + * Copyright 2016 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.dblock; + +import org.keycloak.provider.ProviderFactory; + +/** + * @author Marek Posolda + */ +public interface DBLockProviderFactory extends ProviderFactory { + + /** + * Useful for testing to override provided configuration + */ + void setTimeouts(long lockRecheckTimeMillis, long lockWaitTimeoutMillis); +} diff --git a/server-spi/src/main/java/org/keycloak/models/dblock/DBLockSpi.java b/server-spi/src/main/java/org/keycloak/models/dblock/DBLockSpi.java new file mode 100644 index 0000000000..cd78f0f0e7 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/dblock/DBLockSpi.java @@ -0,0 +1,48 @@ +/* + * Copyright 2016 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.dblock; + +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +/** + * @author Marek Posolda + */ +public class DBLockSpi implements Spi { + + @Override + public boolean isInternal() { + return true; + } + + @Override + public String getName() { + return "dblock"; + } + + @Override + public Class getProviderClass() { + return DBLockProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return DBLockProviderFactory.class; + } +} diff --git a/server-spi/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/server-spi/src/main/resources/META-INF/services/org.keycloak.provider.Spi index 7bb58fc556..ffbb0ac35a 100755 --- a/server-spi/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ b/server-spi/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -21,6 +21,7 @@ org.keycloak.models.RealmSpi org.keycloak.models.UserSessionSpi org.keycloak.models.UserSpi org.keycloak.models.session.UserSessionPersisterSpi +org.keycloak.models.dblock.DBLockSpi org.keycloak.migration.MigrationSpi org.keycloak.hash.PasswordHashSpi org.keycloak.events.EventListenerSpi diff --git a/services/src/main/java/org/keycloak/services/ServicesLogger.java b/services/src/main/java/org/keycloak/services/ServicesLogger.java index 0927f38592..8af67e5d7e 100644 --- a/services/src/main/java/org/keycloak/services/ServicesLogger.java +++ b/services/src/main/java/org/keycloak/services/ServicesLogger.java @@ -401,4 +401,8 @@ public interface ServicesLogger extends BasicLogger { @LogMessage(level = ERROR) @Message(id=90, value="Failed to close ProviderSession") void failedToCloseProviderSession(@Cause Throwable t); + + @LogMessage(level = WARN) + @Message(id=91, value="Forced release of DB lock at startup requested by System property. Make sure to not use this in production environment! And especially when more cluster nodes are started concurrently.") + void forcedReleaseDBLock(); } diff --git a/services/src/main/java/org/keycloak/services/managers/DBLockManager.java b/services/src/main/java/org/keycloak/services/managers/DBLockManager.java new file mode 100644 index 0000000000..1909c36f41 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/managers/DBLockManager.java @@ -0,0 +1,87 @@ +/* + * Copyright 2016 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.services.managers; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.KeycloakSessionTask; +import org.keycloak.models.RealmProvider; +import org.keycloak.models.RealmProviderFactory; +import org.keycloak.models.dblock.DBLockProvider; +import org.keycloak.models.dblock.DBLockProviderFactory; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.services.ServicesLogger; + +/** + * @author Marek Posolda + */ +public class DBLockManager { + + protected static final ServicesLogger logger = ServicesLogger.ROOT_LOGGER; + + public void waitForLock(KeycloakSessionFactory sessionFactory) { + KeycloakModelUtils.runJobInTransaction(sessionFactory, new KeycloakSessionTask() { + + @Override + public void run(KeycloakSession session) { + DBLockProvider lock = getDBLock(session); + lock.waitForLock(); + } + + }); + } + + + public void releaseLock(KeycloakSessionFactory sessionFactory) { + KeycloakModelUtils.runJobInTransaction(sessionFactory, new KeycloakSessionTask() { + + @Override + public void run(KeycloakSession session) { + DBLockProvider lock = getDBLock(session); + lock.releaseLock(); + } + + }); + } + + + public void checkForcedUnlock(KeycloakSessionFactory sessionFactory) { + if (Boolean.getBoolean("keycloak.dblock.forceUnlock")) { + logger.forcedReleaseDBLock(); + releaseLock(sessionFactory); + } + } + + + // Try to detect ID from realmProvider + public DBLockProvider getDBLock(KeycloakSession session) { + String realmProviderId = getRealmProviderId(session); + return session.getProvider(DBLockProvider.class, realmProviderId); + } + + public DBLockProviderFactory getDBLockFactory(KeycloakSession session) { + String realmProviderId = getRealmProviderId(session); + return (DBLockProviderFactory) session.getKeycloakSessionFactory().getProviderFactory(DBLockProvider.class, realmProviderId); + } + + private String getRealmProviderId(KeycloakSession session) { + RealmProviderFactory realmProviderFactory = (RealmProviderFactory) session.getKeycloakSessionFactory().getProviderFactory(RealmProvider.class); + return realmProviderFactory.getId(); + } + +} diff --git a/services/src/main/java/org/keycloak/services/resources/KeycloakApplication.java b/services/src/main/java/org/keycloak/services/resources/KeycloakApplication.java index 0ef806bfe7..55feae3d93 100644 --- a/services/src/main/java/org/keycloak/services/resources/KeycloakApplication.java +++ b/services/src/main/java/org/keycloak/services/resources/KeycloakApplication.java @@ -25,6 +25,7 @@ import org.keycloak.Config; import org.keycloak.exportimport.ExportImportManager; import org.keycloak.migration.MigrationModelManager; import org.keycloak.models.*; +import org.keycloak.services.managers.DBLockManager; import org.keycloak.models.utils.PostMigrationEvent; import org.keycloak.models.utils.RepresentationToModel; import org.keycloak.representations.idm.RealmRepresentation; @@ -39,7 +40,6 @@ import org.keycloak.services.resources.admin.AdminRoot; import org.keycloak.services.scheduled.ClearExpiredEvents; import org.keycloak.services.scheduled.ClearExpiredUserSessions; import org.keycloak.services.scheduled.ClusterAwareScheduledTaskRunner; -import org.keycloak.services.scheduled.ScheduledTaskRunner; import org.keycloak.services.util.JsonConfigProvider; import org.keycloak.services.util.ObjectMapperResolver; import org.keycloak.timer.TimerProvider; @@ -91,44 +91,56 @@ public class KeycloakApplication extends Application { singletons.add(new ObjectMapperResolver(Boolean.parseBoolean(System.getProperty("keycloak.jsonPrettyPrint", "false")))); - migrateModel(); - - boolean bootstrapAdminUser = false; - - KeycloakSession session = sessionFactory.create(); ExportImportManager exportImportManager; + + DBLockManager dbLockManager = new DBLockManager(); + dbLockManager.checkForcedUnlock(sessionFactory); + dbLockManager.waitForLock(sessionFactory); try { - session.getTransaction().begin(); + migrateModel(); - ApplianceBootstrap applianceBootstrap = new ApplianceBootstrap(session); - exportImportManager = new ExportImportManager(session); + KeycloakSession session = sessionFactory.create(); + try { + session.getTransaction().begin(); - boolean createMasterRealm = applianceBootstrap.isNewInstall(); - if (exportImportManager.isRunImport() && exportImportManager.isImportMasterIncluded()) { - createMasterRealm = false; + ApplianceBootstrap applianceBootstrap = new ApplianceBootstrap(session); + exportImportManager = new ExportImportManager(session); + + boolean createMasterRealm = applianceBootstrap.isNewInstall(); + if (exportImportManager.isRunImport() && exportImportManager.isImportMasterIncluded()) { + createMasterRealm = false; + } + + if (createMasterRealm) { + applianceBootstrap.createMasterRealm(contextPath); + } + session.getTransaction().commit(); + } catch (RuntimeException re) { + if (session.getTransaction().isActive()) { + session.getTransaction().rollback(); + } + throw re; + } finally { + session.close(); } - if (createMasterRealm) { - applianceBootstrap.createMasterRealm(contextPath); + if (exportImportManager.isRunImport()) { + exportImportManager.runImport(); + } else { + importRealms(); } - session.getTransaction().commit(); + + importAddUser(); } finally { - session.close(); + dbLockManager.releaseLock(sessionFactory); } - if (exportImportManager.isRunImport()) { - exportImportManager.runImport(); - } else { - importRealms(); - } - - importAddUser(); - if (exportImportManager.isRunExport()) { exportImportManager.runExport(); } - session = sessionFactory.create(); + boolean bootstrapAdminUser = false; + KeycloakSession session = sessionFactory.create(); try { session.getTransaction().begin(); bootstrapAdminUser = new ApplianceBootstrap(session).isNoMasterUser(); diff --git a/testsuite/integration/pom.xml b/testsuite/integration/pom.xml index 531a2ae3ae..6660302226 100755 --- a/testsuite/integration/pom.xml +++ b/testsuite/integration/pom.xml @@ -237,6 +237,16 @@ test + + mysql + mysql-connector-java + + + org.postgresql + postgresql + ${postgresql.version} + + @@ -468,41 +478,6 @@ - - - - - keycloak.connectionsJpa.driver - com.mysql.jdbc.Driver - - - mysql - - - mysql - mysql-connector-java - - - - - - - - - keycloak.connectionsJpa.driver - org.postgresql.Driver - - - postgresql - - - org.postgresql - postgresql - ${postgresql.version} - - - - clean-jpa diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/DBLockTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/DBLockTest.java new file mode 100644 index 0000000000..f33362ce50 --- /dev/null +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/DBLockTest.java @@ -0,0 +1,173 @@ +/* + * Copyright 2016 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; + +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.jboss.logging.Logger; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.KeycloakSessionTask; +import org.keycloak.services.managers.DBLockManager; +import org.keycloak.models.dblock.DBLockProvider; +import org.keycloak.models.dblock.DBLockProviderFactory; +import org.keycloak.models.utils.KeycloakModelUtils; + +/** + * @author Marek Posolda + */ +public class DBLockTest extends AbstractModelTest { + + private static final Logger log = Logger.getLogger(DBLockTest.class); + + private static final int SLEEP_TIME_MILLIS = 10; + private static final int THREADS_COUNT = 20; + private static final int ITERATIONS_PER_THREAD = 2; + + private static final int LOCK_TIMEOUT_MILLIS = 240000; // Rather bigger to handle slow DB connections in testing env + private static final int LOCK_RECHECK_MILLIS = 10; + + @Before + @Override + public void before() throws Exception { + super.before(); + + // Set timeouts for testing + DBLockManager lockManager = new DBLockManager(); + DBLockProviderFactory lockFactory = lockManager.getDBLockFactory(session); + lockFactory.setTimeouts(LOCK_RECHECK_MILLIS, LOCK_TIMEOUT_MILLIS); + + // Drop lock table, just to simulate racing threads for create lock table and insert lock record into it. + lockManager.getDBLock(session).destroyLockInfo(); + + commit(); + } + + @Test + public void testLockConcurrently() throws Exception { + long startupTime = System.currentTimeMillis(); + + final Semaphore semaphore = new Semaphore(); + final KeycloakSessionFactory sessionFactory = realmManager.getSession().getKeycloakSessionFactory(); + + List threads = new LinkedList<>(); + for (int i=0 ; i(expected.getDefaultRoles()), new HashSet<>(actual.getDefaultRoles())); Assert.assertEquals(expected.getSmtpConfig(), actual.getSmtpConfig()); diff --git a/testsuite/integration/src/test/resources/log4j.properties b/testsuite/integration/src/test/resources/log4j.properties index a199a4174d..fd730de9eb 100755 --- a/testsuite/integration/src/test/resources/log4j.properties +++ b/testsuite/integration/src/test/resources/log4j.properties @@ -39,7 +39,7 @@ log4j.logger.org.keycloak.testsuite=${keycloak.testsuite.logging.level} # Liquibase updates logged with "info" by default. Logging level can be changed by system property "keycloak.liquibase.logging.level" keycloak.liquibase.logging.level=info -log4j.logger.org.keycloak.connections.jpa.updater.liquibase.LiquibaseJpaUpdaterProvider=${keycloak.liquibase.logging.level} +log4j.logger.org.keycloak.connections.jpa.updater.liquibase=${keycloak.liquibase.logging.level} # Enable to view infinispan initialization # log4j.logger.org.keycloak.models.sessions.infinispan.initializer=trace