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
10000
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:
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 @@ -38,6 +38,7 @@
import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider;
import org.keycloak.models.KeycloakSession;

import org.hibernate.Session;
import org.hibernate.dialect.Dialect;
import org.hibernate.dialect.MySQLDialect;
import org.hibernate.dialect.OracleDialect;
Expand All @@ -59,6 +60,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 SKIP_LOCKED_SUPPORTED_PROPERTY = "keycloak.skip-locked-supported";

/**
* MySQL/MariaDB drivers default to CLIENT_FOUND_ROWS, which makes ON DUPLICATE KEY UPDATE
Expand All @@ -67,10 +69,40 @@ 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;
}
Map<String, Object> properties = em.getEntityManagerFactory().getProperties();
Object cached = properties.get(SKIP_LOCKED_SUPPORTED_PROPERTY);
if (cached != null) {
return (Boolean) cached;
}
boolean supported = em.unwrap(Session.class).doReturningWork(connection -> {
String version = connection.getMetaData().getDatabaseProductVersion();
return version == null || !version.contains("-TiDB-");
});
// the result is stored with the factory, as the database is the same for all entity managers created from it
properties.put(SKIP_LOCKED_SUPPORTED_PROPERTY, supported);
return supported;
}

/**
* 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)
.setParame 10000 ter("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 @@ -14,6 +14,9 @@
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
Expand Down Expand Up @@ -361,8 +364,12 @@ private class OutputHandler implements Runnable {
private static final Pattern LOG_PATTERN = Pattern.compile("([^ ]*) ([^ ]*) ([A-Z]*)([ ]*)(.*)");
private static final Logger LOGGER = Logger.getLogger("managed.keycloak");

private static final long FLUSH_DELAY_MS = 100;

private boolean startedInPrinted = false;
private final Process process;
private Logger.Level recordLevel;
private StringBuilder record;

private CountDownLatch startupLatch = new CountDownLatch(1);

Expand All @@ -374,36 +381,73 @@ private OutputHandler(Process process) {
public void run() {
InputStream is = process.getInputStream();
BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8));
// A log record can span multiple lines (for example a stack trace), collect them and log them as one statement.
// As the reader blocks, a record that has not received a new line for a short time is flushed by a timer.
ScheduledExecutorService flusher = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "managed-keycloak-log-flusher");
t.setDaemon(true);
return t;
});
ScheduledFuture<?> pendingFlush = null;
try {
for (String line = br.readLine(); process.isAlive() && line != null; line = br.readLine()) {
if (!startedInPrinted && line.matches(".*Keycloak.* started in.*")) {
startupLatch.countDown();
}

Logger.Level level = null;
String message = null;
Matcher matcher = LOG_PATTERN.matcher(line);
if (matcher.matches()) {
String levelString = matcher.group(3);
String message = matcher.group(5);
if (levelString != null && message != null) {
for (Logger.Level l : Logger.Level.values()) {
if (l.name().equals(levelString)) {
LOGGER.log(l, message);
break;
}
if (matcher.matches() && matcher.group(5) != null) {
for (Logger.Level l : Logger.Level.values()) {
if (l.name().equals(matcher.group(3))) {
level = l;
message = matcher.group(5);
break;
}
}
}
LOGGER.info(line);

synchronized (this) {
if (pendingFlush != null) {
pendingFlush.cancel(false);
}
if (level != null) {
flush();
recordLevel = level;
record = new StringBuilder(message);
} else if (record != null) {
record.append('\n').append(line);
} else if (recordLevel != null) {
record = new StringBuilder(line);
} else {
LOGGER.info(line);
}
pendingFlush = flusher.schedule(this::flushSynchronized, FLUSH_DELAY_MS, TimeUnit.MILLISECONDS);
}
}
flushSynchronized();
} catch (IOException e) {
// Ignored
} finally {
flusher.shutdown();
if (startupLatch.getCount() != 0) {
startupLatch.countDown();
}
}
}

private synchronized void flushSynchronized() {
flush();
}

private void flush() {
if (record != null) {
LOGGER.log(recordLevel, record.toString());
record = null;
}
}

public boolean waitForStarted() {
try {
startupLatch.await(startTimeout, TimeUnit.SECONDS);
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
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,6 @@ public RealmBuilder configure(RealmBuilder realm) {
ClientBuilder.create("root-url-client")
.id(KeycloakModelUtils.generateId())
.rootUrl("http://localhost:8180/foo/bar")
.adminUrl("http://localhost:8180/foo/bar")
.baseUrl("/baz")
.redirectUris(
"http://localhost:8180/foo/bar/*",
Expand Down
Loading
Loading
0