diff --git a/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/custom/JpaUpdate26_2_6_RemoveDuplicateMigrationModelTime.java b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/custom/JpaUpdate26_2_6_RemoveDuplicateMigrationModelTime.java
new file mode 100644
index 00000000000..2ed02eeb536
--- /dev/null
+++ b/model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/custom/JpaUpdate26_2_6_RemoveDuplicateMigrationModelTime.java
@@ -0,0 +1,84 @@
+package org.keycloak.connections.jpa.updater.liquibase.custom;
+
+import liquibase.exception.CustomChangeException;
+import liquibase.statement.core.DeleteStatement;
+import liquibase.structure.core.Column;
+import org.keycloak.migration.ModelVersion;
+
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+/**
+ * Cleanup script for removing duplicated migration model update time in the MIGRATION_MODEL table
+ * See: keycloak#40088
+ */
+public class JpaUpdate26_2_6_RemoveDuplicateMigrationModelTime extends CustomKeycloakTask {
+ private final static String MIGRATION_MODEL_TABLE = "MIGRATION_MODEL";
+
+ @Override
+ protected String getTaskId() {
+ return "Delete duplicated records for DB update time in MIGRATION_MODEL table";
+ }
+
+ @Override
+ protected void generateStatementsImpl() throws CustomChangeException {
+ final Map itemsToDelete = new HashMap<>();
+
+ final String tableName = getTableName(MIGRATION_MODEL_TABLE);
+ final String colId = database.correctObjectName("ID", Column.class);
+ final String colVersion = database.correctObjectName("VERSION", Column.class);
+ final String colUpdateTime = database.correctObjectName("UPDATE_TIME", Column.class);
+
+ final String GET_DUPLICATED_RECORDS = """
+ SELECT m1.%s, m1.%s
+ FROM %s m1
+ WHERE EXISTS (
+ SELECT m2.%s
+ FROM %s m2
+ WHERE m2.%s = m1.%s AND m2.%s <> m1.%s
+ )
+ """.formatted(
+ colId, colVersion, // SELECT m1.%s, m1.%s => SELECT m1.ID, m1.VERSION
+ tableName, // FROM %s m1 => FROM MIGRATION_MODEL m1
+ colId, // SELECT m2.%s => SELECT m2.ID
+ tableName, // FROM %s m2 => FROM MIGRATION_MODEL m2
+ // WHERE m2.%s = m1.%s AND m2.%s <> m1.%s => WHERE m2.UPDATE_TIME = m1.UPDATE_TIME AND m2.ID <> m1.ID
+ colUpdateTime, colUpdateTime, colId, colId
+ );
+
+ //noinspection SqlSourceToSinkFlow
+ try (PreparedStatement ps = connection.prepareStatement(GET_DUPLICATED_RECORDS)) {
+ ResultSet resultSet = ps.executeQuery();
+ while (resultSet.next()) {
+ String id = resultSet.getString(1);
+ ModelVersion version = new ModelVersion(resultSet.getString(2));
+ itemsToDelete.put(id, version);
+ }
+ } catch (Exception e) {
+ throw new CustomChangeException(getTaskId() + ": Failed to detect duplicate MIGRATION_MODEL rows", e);
+ }
+
+ // Get ID of the highest Keycloak version with the same update time
+ var highestVersionId = itemsToDelete.entrySet()
+ .stream()
+ .reduce((e1, e2) -> e1.getValue().lessThan(e2.getValue()) ? e2 : e1)
+ .map(Map.Entry::getKey)
+ .orElse(null);
+
+ AtomicInteger i = new AtomicInteger();
+ itemsToDelete.keySet().stream()
+ .filter(f -> !f.equals(highestVersionId))
+ .collect(Collectors.groupingByConcurrent(id -> i.getAndIncrement() / 20, Collectors.toList())) // Split into chunks of at most 20 items
+ .values().stream()
+ .map(ids -> new DeleteStatement(null, null, MIGRATION_MODEL_TABLE)
+ .setWhere(":name IN (" + ids.stream().map(id -> "?").collect(Collectors.joining(",")) + ")")
+ .addWhereColumnName(colId)
+ .addWhereParameters(ids.toArray())
+ )
+ .forEach(statements::add);
+ }
+}
diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-26.2.6.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.2.6.xml
index a8885e2ada1..98e1c8481ed 100644
--- a/model/jpa/src/main/resources/META-INF/jpa-changelog-26.2.6.xml
+++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.2.6.xml
@@ -25,4 +25,12 @@
+
+
+
+
+
+
+
+
diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/MigrationModelTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/MigrationModelTest.java
index 551f1b8032b..611a443c686 100644
--- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/MigrationModelTest.java
+++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/MigrationModelTest.java
@@ -159,6 +159,47 @@ public class MigrationModelTest extends KeycloakModelTest {
});
}
+ @Test
+ public void duplicatedUpdateTime() {
+ inComittedTransaction(1, (session, i) -> {
+ String currentVersion = new ModelVersion(Version.VERSION).toString();
+ EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
+
+ List entities = getMigrationEntities(em);
+ assertThat(entities.size(), is(1));
+ assertMigrationModelEntity(entities.get(0), currentVersion);
+
+ MigrationModel m = session.getProvider(DeploymentStateProvider.class).getMigrationModel();
+ assertThat(m.getStoredVersion(), is(currentVersion));
+ assertThat(entities.get(0).getId(), is(m.getResourcesTag()));
+
+ try {
+ MigrationModelEntity mm1 = new MigrationModelEntity();
+ mm1.setId("a");
+ mm1.setUpdatedTime(0);
+ mm1.setVersion("26.0.0");
+ em.persist(mm1);
+
+ em.flush();
+
+ // Same time, everything different - testing for the constraint to be present
+ MigrationModelEntity mm2 = new MigrationModelEntity();
+ mm2.setId("b");
+ mm2.setUpdatedTime(0);
+ mm2.setVersion("26.0.1");
+ em.persist(mm2);
+
+ // added at the same time - exception thrown by the unique constraint
+ assertThrows(ModelDuplicateException.class, em::flush);
+
+ } finally {
+ em.remove(em.find(MigrationModelEntity.class, "a"));
+ }
+
+ return null;
+ });
+ }
+
private void assertMigrationModelEntity(MigrationModelEntity model, String expectedVersion) {
assertThat(model, notNullValue());
assertTrue(model.getId().matches("[\\da-z]{5}"));