diff --git a/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetGroupResourceTest.java b/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetGroupResourceTest.java index b510e4161..fa6a44765 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetGroupResourceTest.java +++ b/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetGroupResourceTest.java @@ -27,6 +27,7 @@ import org.eclipse.hawkbit.repository.model.TargetTag; import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; public class MgmtTargetGroupResourceTest extends AbstractManagementApiIntegrationTest { @@ -257,4 +258,40 @@ public class MgmtTargetGroupResourceTest extends AbstractManagementApiIntegratio .andExpect(jsonPath("content", Matchers.hasSize(1))) .andExpect(jsonPath("content.[0].controllerId", Matchers.equalTo("target3"))); } + + @Test + void shouldAssignGroupInChunksWhenTargetCountExceedsChunkSize() throws Exception { + // create 5 targets with tag "exclude", 2 without — chunk size 2 forces multiple batches + final TargetTag excludeTag = targetTagManagement.create(TargetTagManagement.Create.builder().name("exclude").build()); + for (int i = 1; i <= 5; i++) { + targetManagement.create(builder().controllerId("chunked-" + i).build()); + } + targetManagement.create(builder().controllerId("excluded-1").build()); + targetManagement.create(builder().controllerId("excluded-2").build()); + targetManagement.assignTag(List.of("excluded-1", "excluded-2"), excludeTag.getId()); + + // override chunk size to 2 for this test + ReflectionTestUtils.setField(targetManagement, "assignTargetGroupChunkSize", 2); + try { + // tag!=exclude triggers chunked path and should assign only non-tagged targets + mvc.perform(put(MgmtTargetGroupRestApi.TARGETGROUPS_V1 + "/ChunkedGroup") + .contentType(MediaType.APPLICATION_JSON) + .param("q", "tag!=exclude")) + .andExpect(status().isNoContent()); + + mvc.perform(get(MgmtTargetGroupRestApi.TARGETGROUPS_V1 + "/ChunkedGroup/assigned") + .param(MgmtRestConstants.REQUEST_PARAMETER_SORTING, "ID:ASC") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("content", Matchers.hasSize(5))) + .andExpect(jsonPath("content.[0].controllerId", Matchers.equalTo("chunked-1"))) + .andExpect(jsonPath("content.[1].controllerId", Matchers.equalTo("chunked-2"))) + .andExpect(jsonPath("content.[2].controllerId", Matchers.equalTo("chunked-3"))) + .andExpect(jsonPath("content.[3].controllerId", Matchers.equalTo("chunked-4"))) + .andExpect(jsonPath("content.[4].controllerId", Matchers.equalTo("chunked-5"))); + } finally { + // restore default + ReflectionTestUtils.setField(targetManagement, "assignTargetGroupChunkSize", 1000); + } + } } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaTargetManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaTargetManagement.java index a5d2f0e3b..a34f2cc23 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaTargetManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaTargetManagement.java @@ -29,12 +29,11 @@ import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.CriteriaUpdate; import jakarta.persistence.criteria.MapJoin; -import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; -import jakarta.persistence.criteria.Subquery; import jakarta.persistence.metamodel.MapAttribute; import jakarta.validation.constraints.NotEmpty; +import lombok.extern.slf4j.Slf4j; import org.eclipse.hawkbit.ql.jpa.QLSupport; import org.eclipse.hawkbit.repository.QuotaManagement; import org.eclipse.hawkbit.repository.TargetManagement; @@ -58,6 +57,7 @@ import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.Target; import org.eclipse.hawkbit.repository.model.TargetTag; import org.eclipse.hawkbit.repository.qfields.TargetFields; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.dao.ConcurrencyFailureException; import org.springframework.data.domain.Page; @@ -72,6 +72,7 @@ import org.springframework.validation.annotation.Validated; /** * JPA implementation of {@link TargetManagement}. */ +@Slf4j @Transactional(readOnly = true) @Validated @Service @@ -85,6 +86,9 @@ public class JpaTargetManagement private final TargetTypeRepository targetTypeRepository; private final TargetTagRepository targetTagRepository; + @Value("${hawkbit.target-group.assign.chunk-size:1000}") + private int assignTargetGroupChunkSize; + @SuppressWarnings("java:S107") protected JpaTargetManagement( final TargetRepository jpaRepository, final EntityManager entityManager, @@ -335,30 +339,62 @@ public class JpaTargetManagement @Transactional @Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY) public void assignTargetGroupWithRsql(String group, String rsql) { - final Specification rsqlSpecification = QLSupport.getInstance().buildSpec(rsql, TargetFields.class); - final CriteriaBuilder cb = entityManager.getCriteriaBuilder(); - final CriteriaUpdate criteriaUpdateQuery = cb.createCriteriaUpdate(JpaTarget.class); - final Root updateRoot = criteriaUpdateQuery.getRoot(); - criteriaUpdateQuery.set("group", group); - - if (Jpa.JPA_VENDOR == Jpa.JpaVendor.ECLIPSELINK) { - // EclipseLink: use subquery approach — applying predicate directly to the UPDATE root - // fails for NOT EXISTS due to UpdateAllQuery's @Id resolution bug - // BUG Reported: https://github.com/eclipse-ee4j/eclipselink/issues/2757 - final Subquery subquery = criteriaUpdateQuery.subquery(Long.class); - final Root subRoot = subquery.from(JpaTarget.class); - subquery.select(subRoot.get(AbstractJpaBaseEntity_.ID)); - subquery.where(rsqlSpecification.toPredicate(subRoot, cb.createQuery(JpaTarget.class), cb)); - criteriaUpdateQuery.where(updateRoot.get(AbstractJpaBaseEntity_.ID).in(subquery)); + // Switch back to UpdateAllQuery if switching back to hibernate. (EclipseLink does not work well with UpdateAllQuery) + // EclipseLink: using subquery approach — applying predicate directly to the UPDATE root + // fails for NOT EXISTS due to UpdateAllQuery's @Id resolution bug + // BUG Reported: https://github.com/eclipse-ee4j/eclipselink/issues/2757 + // Hibernate: applies predicate directly to the UPDATE root — Hibernate handles + // NOT EXISTS subqueries correctly in CriteriaUpdate context. So this problem does not exist there. + if (Jpa.JPA_VENDOR == Jpa.JpaVendor.ECLIPSELINK && containsNegation(rsql)) { + log.debug("Assigning group {} with rsql {} on chunks.", group, rsql); + assignTargetGroupOnChunks(group, rsql); } else { - // Hibernate: apply predicate directly to the UPDATE root — Hibernate handles - // NOT EXISTS subqueries correctly in CriteriaUpdate context - final Predicate predicate = rsqlSpecification.toPredicate(updateRoot, cb.createQuery(JpaTarget.class), cb); - criteriaUpdateQuery.where(predicate); + log.debug("Assigning group {} with rsql {} with batch update", group, rsql); + assignTargetGroupDirect(group, rsql); } + } - entityManager.createQuery(criteriaUpdateQuery).executeUpdate(); + private static boolean containsNegation(final String rsql) { + return rsql.contains("!=") || rsql.contains("=out=") || rsql.contains("=notlike="); + } + + private void assignTargetGroupDirect(final String group, final String rsql) { + final Specification rsqlSpecification = QLSupport.getInstance().buildSpec(rsql, TargetFields.class); + final CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + final CriteriaUpdate update = cb.createCriteriaUpdate(JpaTarget.class); + final Root root = update.getRoot(); + update.set("group", group); + update.where(rsqlSpecification.toPredicate(root, cb.createQuery(JpaTarget.class), cb)); + entityManager.createQuery(update).executeUpdate(); + } + + private void assignTargetGroupOnChunks(final String group, final String rsql) { + final Specification spec = QLSupport.getInstance().buildSpec(rsql, TargetFields.class); + final CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + // SELECT Target IDs + final CriteriaQuery select = cb.createQuery(Long.class); + final Root root = select.from(JpaTarget.class); + select.select(root.get(AbstractJpaBaseEntity_.ID)); + select.where(spec.toPredicate(root, select, cb)); + + List chunk; + int offset = 0; + do { + chunk = entityManager.createQuery(select) + .setFirstResult(offset) + .setMaxResults(assignTargetGroupChunkSize) + .getResultList(); + + if (chunk.isEmpty()) { + break; + } + final CriteriaUpdate update = cb.createCriteriaUpdate(JpaTarget.class); + update.set("group", group); + update.where(update.getRoot().get(AbstractJpaBaseEntity_.ID).in(chunk)); + entityManager.createQuery(update).executeUpdate(); + offset += chunk.size(); + } while (true); } @Override