8000 Evaluate TiDB as a MySQL database in tests by ahus1 · Pull Request #54725 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ jobs:
ci-aurora: ${{ steps.auroradb-tests.outputs.run-aurora-tests }}
ci-azure: ${{ steps.azure-tests.outputs.run-azure-tests }}
ci-additional-dbs: ${{ steps.additional-dbs-tests.outputs.run-additional-dbs-tests }}
ci-tidb: ${{ steps.tidb-tests.outputs.run-tidb-tests }}
ci-admin-v2: ${{ steps.conditional.outputs.admin-v2 }}
ci-jackson3: ${{ steps.conditional.outputs.ci-jackson3 }}
permissions:
Expand Down Expand Up @@ -98,6 +99,24 @@ jobs:
env:
PRIVATE_DBS_QUAY_USERNAME: ${{ secrets.PRIVATE_DBS_QUAY_USERNAME }}
PRIVATE_DBS_QUAY_TOKEN: ${{ secrets.PRIVATE_DBS_QUAY_TOKEN }}
- name: TiDB conditional check
id: tidb-tests
# Runs for manually or nightly triggered runs, and for pushes/PRs when any commit message in the PR/push contains "[tidb ci]"
run: |
RUN_TIDB_TESTS=false
if [[ $GITHUB_EVENT_NAME == "pull_request" ]]; then
COMMIT_MESSAGE="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/commits" --jq '.[].commit.message')"
else
COMMIT_MESSAGE="${PUSH_COMMIT_MESSAGES}"
fi
if [[ $GITHUB_EVENT_NAME == "workflow_dispatch" || $GITHUB_EVENT_NAME == "schedule" || "${COMMIT_MESSAGE}" == *"[tidb ci]"* ]]; then
RUN_TIDB_TESTS=true
fi
echo "run-tidb-tests=$RUN_TIDB_TESTS" >> $GITHUB_OUTPUT
env:
8000 GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PUSH_COMMIT_MESSAGES: ${{ join(github.event.commits.*.message, '\n') }}
testsuite-deprecation-check:
name: Testsuite Deprecation Check
runs-on: ubuntu-latest
Expand Down Expand Up @@ -877,6 +896,21 @@ jobs:
with:
db: ${{ matrix.db }}

store-integration-tests-tidb:
name: Store IT (TiDB)
needs: [build, conditional]
runs-on: ubuntu-latest
timeout-minutes: 85
if: needs.conditional.outputs.ci-tidb == 'true'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- id: run-store-tests
name: Run Store Integration Tests - tidb
uses: ./.github/actions/run-store-tests
with:
db: tidb

store-model-tests:
name: Store Model Tests
runs-on: ubuntu-latest
Expand Down Expand Up @@ -1362,6 +1396,7 @@ jobs:
- jdk-integration-tests
- store-integration-tests
- store-integration-tests-additional
- store-integration-tests-tidb
- volatile-sessions-tests
- http-client-v2-tests
- store-model-tests
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
Expand Down Expand Up @@ -59,6 +63,7 @@ public class JpaUtils {
public static final String QUERY_NATIVE_SUFFIX = "[native]";
public static final String QUERY_JPQL_SUFFIX = "[jpql]";
private static final Logger logger = Logger.getLogger(JpaUtils.class);
private static final String TIDB_PROPERTY = "keycloak.tidb";

/**
* MySQL/MariaDB drivers default to CLIENT_FOUND_ROWS, which makes ON DUPLICATE KEY UPDATE
Expand All @@ -67,10 +72,76 @@ public class JpaUtils {
* no-op update on an active row is indistinguishable from a successful insert (both return 1).
*/
public static boolean isUpsertRowCountUnreliable(EntityManager em) {
return isMySQLDialect(em);
}

public static boolean isMySQLDialect(EntityManager em) {
return em.getEntityManagerFactory().unwrap(SessionFactoryImplementor.class)
.getJdbcServices().getDialect() instanceof MySQLDialect;
}

/**
* TiDB is used via the MySQL dialect, and accepts {@code FOR UPDATE SKIP LOCKED} but silently ignores the
* {@code SKIP LOCKED} part: rows locked by other transactions are returned instead of being skipped, so a query
* using it doesn't prove that the row is locked by the current transaction.
* TiDB can only be identified by its version string, which looks like {@code 8.0.11-TiDB-v8.5.8}.
* See <a href="https://github.com/pingcap/tidb/issues/18207">pingcap/tidb#18207</a>.
*/
public static boolean isSkipLockedSupported(EntityManager em) {
if (!isMySQLDialect(em)) {
// not a MySQL dialect
return true;
}
return !Boolean.TRUE.equals(em.getEntityManagerFactory().getProperties().get(TIDB_PROPERTY));
}

/**
* Detects database products that are used via the dialect of another database and can only be identified by their
* version string. Needs to be called once while the server starts, before any request is handled, so the result can be
* read without synchronization afterwards.
*/
public static void detectDatabaseProduct(EntityManagerFactory emf, Connection connection) throws SQLException {
SessionFactoryImplementor factory = emf.unwrap(SessionFactoryImplementor.class);
boolean tidb = false;
if (factory.getJdbcServices().getDialect() instanceof MySQLDialect) {
// TiDB reports a version like 8.0.11-TiDB-v8.5.8
String version = connection.getMetaData().getDatabaseProductVersion();
tidb = version != null && version.contains("-TiDB-");
}
emf.getProperties().put(TIDB_PROPERTY, tidb);
if (tidb) {
warnIfTiDBCommitOptimizationsDisabled(connection);
}
}

/**
* Async commit and one-phase commit roughly halve the latency of writes on TiDB. They are enabled by default
* on TiDB clusters, but might be disabled on clusters that were upgraded from an older version.
* The single-node {@code unistore} that is embedded in the TiDB container image commits locally and doesn't benefit from them.
*/
private static void warnIfTiDBCommitOptimizationsDisabled(Connection connection) {
try (Statement statement = connection.createStatement();
ResultSet config = statement.executeQuery("SELECT @@tidb_config")) {
if (config.next() && config.getString(1).replaceAll("\\s", "").contains("\"store\":\"unistore\"")) {
return;
}
} catch (SQLException e) {
logger.debug("Unable to read the TiDB configuration", e);
}
try (Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery("SHOW VARIABLES WHERE Variable_name IN ('tidb_enable_async_commit', 'tidb_enable_1pc')")) {
while (rs.next()) {
String name = rs.getString(1);
String value = rs.getString(2);
if (!"ON".equalsIgnoreCase(value) && !"1".equals(value)) {
logger.warnf("TiDB variable '%s' is set to '%s'. Set it to 'ON' to reduce the latency of writes.", name, value);
}
}
} catch (SQLException e) {
logger.debug("Unable to check the TiDB commit optimization settings", e);
}
}

/**
* Oracle emulates {@code on conflict ... do update} with a MERGE, which fails with a unique constraint violation
* when a concurrent transaction inserts the same key. {@code on conflict ... do nothing} is safe there, see
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,14 @@

import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import jakarta.persistence.LockTimeoutException;
import jakarta.persistence.PessimisticLockException;
import jakarta.persistence.Query;
import jakarta.persistence.TypedQuery;

import org.keycloak.common.util.MultiSiteUtils;
import org.keycloak.common.util.Time;
import org.keycloak.connections.jpa.util.JpaUtils;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
Expand Down Expand Up @@ -331,6 +334,35 @@ public UserSessionModel loadUserSession(RealmModel realm, String userSessionId,
return handleSingleQuery(entity, offlineStr);
}

/**
* Lock the row and return its version, or null if the row doesn't exist or is locked by someone else.
* Don't wait for rows locked by others, as the caller will retry in a separate transaction.
* <p>
* The NOWAIT fallback is intended only for TiDB (see {@link JpaUtils#isSkipLockedSupported(EntityManager)}),
* which ignores SKIP LOCKED. It is not generic: it relies on Hibernate not marking the transaction as
* rollback-only when a lock with a timeout of 0 fails, which holds for the MySQL dialect but not for others
* (for example, the PostgreSQL dialect always marks the transaction for rollback, as the database aborts it anyway).
* See {@code UserSessionPersisterLockTest}, which verifies this behavior.
* Waiting instead would risk deadlocks when rows are locked in different orders.
*/
private Integer lockVersion(TypedQuery<Integer> query) {
if (JpaUtils.isSkipLockedSupported(em)) {
query.setHint(LOCK_TIMEOUT, -2);
return query.getSingleResultOrNull();
}
if (!JpaUtils.isMySQLDialect(em)) {
// the fallback is not generic and may break transactions in other databases
throw new UnsupportedOperationException("Locking rows without skip locked is not supported for this database");
}
query.setHint(LOCK_TIMEOUT, 0);
try {
return query.getSingleResultOrNull();
} catch (PessimisticLockException | LockTimeoutException e) {
logger.debugf("Row is locked by another transaction: %s", e.getMessage());
return null;
}
}

public boolean lockUserSession(RealmModel realm, String userSessionId, boolean offline, boolean isDelete) {
String offlineStr = offlineToString(offline);
PersistentUserSessionEntity entity = em.find(PersistentUserSessionEntity.class, new PersistentUserSessionEntity.Key(userSessionId, offlineStr));
Expand All @@ -343,12 +375,11 @@ public boolean lockUserSession(RealmModel realm, String userSessionId, boolean o
int knownVersion = entity.getVersion();

// Fetch the entry and lock the row but only if it is not locked already
Integer currentVersion = em.createQuery("select version from PersistentUserSessionEntity where userSessionId = :userSessionId and offline = :offline", Integer.class)
TypedQuery<Integer> lockQuery = em.createQuery("select version from PersistentUserSessionEntity where userSessionId = :userSessionId and offline = :offline", Integer.class)
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
.setHint(LOCK_TIMEOUT, -2)
.setParameter("userSessionId", userSessionId)
.setParameter("offline", offlineStr)
.getSingleResultOrNull();
.setParameter("offline", offlineStr);
Integer currentVersion = lockVersion(lockQuery);

boolean result = currentVersion != null && knownVersion == currentVersion;
logger.debugf("User session %s/%s lock status: %b", userSessionId, offlineStr, result);
Expand Down Expand Up @@ -493,15 +524,14 @@ public boolean lockClientSession(RealmModel realm, String userSessionId, String
}
int knownVersion = entity.getVersion();

Integer currentVersion = em.createQuery("select version from PersistentClientSessionEntity where userSessionId = :userSessionId and offline = :offline and clientId = :clientId and externalClientId = :externalClientId and clientStorageProvider = :clientStorageProvider", Integer.class)
TypedQuery<Integer> lockQuery = em.createQuery("select version from PersistentClientSessionEntity where userSessionId = :userSessionId and offline = :offline and clientId = :clientId and externalClientId = :externalClientId and clientStorageProvider = :clientStorageProvider", Integer.class)
.setParameter("userSessionId", key.userSessionId)
.setParameter("offline", key.offline)
.setParameter("clientId", key.clientId)
.setParameter("clientStorageProvider", key.getClientStorageProvider())
.setParameter("externalClientId", key.getExternalClientId())
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
.setHint(LOCK_TIMEOUT, -2)
.getSingleResultOrNull();
.setLockMode(LockModeType.PESSIMISTIC_WRITE);
Integer currentVersion = lockVersion(lockQuery);

boolean locked = currentVersion != null && knownVersion == currentVersion;
logger.debugf("User session %s/%s/%s lock status: %b", userSessionId, clientId, offline, locked);
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@
<twitter4j.version>4.1.2</twitter4j.version>

<!-- Databases - also published to db.adoc as "Tested Version" -->
<tidb.version>v8.5.2</tidb.version>
<tidb.version>v8.5.8</tidb.version>
<tidb.container>mirror.gcr.io/pingcap/tidb:${tidb.version}</tidb.container>
<mysql.version>8.4</mysql.version>
<mysql.container>mirror.gcr.io/mysql:${mysql.version}</mysql.container>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ public void postInit(KeycloakSessionFactory factory) {
logErrorSettingMigrationTransactionTimeout(e);
}
try (Connection connection = getConnection(); KeycloakSession session = factory.create()) {
JpaUtils.detectDatabaseProduct(entityManagerFactory, connection);
try {
try (Statement statement = connection.createStatement()) {
try (ResultSet rs = statement.executeQuery(String.format(SQL_GET_LATEST_VERSION, getSchema(schema)))) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,12 @@
package org.keycloak.testframework.database;

import org.keycloak.common.Profile;
import org.keycloak.testframework.annotations.InjectTestDatabase;
import org.keycloak.testframework.injection.InstanceContext;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;

public class TiDBDatabaseSupplier extends AbstractContainerDatabaseSupplier {

@Override
public String getAlias() {
return "tidb";
}

@Override
public KeycloakServerConfigBuilder intercept(KeycloakServerConfigBuilder serverConfig, InstanceContext<TestDatabase, InjectTestDatabase> instanceContext) {
KeycloakServerConfigBuilder builder = super.intercept(serverConfig, instanceContext);
builder.features(Profile.Feature.DB_TIDB);
return builder;
}

@Override
TestDatabase getTestDatabase() {
return new TiDBTestDatabase();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ public TiDBContainer withPassword(String password) {

@Override
public String getDatabaseVendor() {
return "tidb";
// TiDB is tested as a plain MySQL database
return "mysql";
}

@Override
Expand Down
Loading
Loading
0