From f061cceb958b51e885d4447201b90605e3497bd5 Mon Sep 17 00:00:00 2001 From: Jason Pickering Date: Wed, 24 Jun 2026 09:43:35 +0200 Subject: [PATCH] perf: skip org unit/period resolution in dimension recommendations [2.40] Backport of master commit f61988aa36 (PR #24264) onto 2.40. The /api/dimensions/recommendations endpoint resolved every dimension on the request via DataQueryService.getFromRequest, including ou:LEVEL-*. On a large hierarchy this hydrates all org units at the level as full entities and sorts them (~4.5s CPU, ~3.2 GB allocated in production profiling) -- but getRecommendedDimensions only reads the data (dx) dimension's data elements/program indicators, so the org unit (and period) resolution is discarded. Scope the request to the dx dimension before resolving, so getFromRequest never resolves/hydrates the unused dimensions. Behaviour-equivalent for the sole caller (DimensionController sets only the dimension param), with no change to DataQueryService/getOrganisationUnitsAtLevels callers. 2.40 adaptations vs the master commit: - DATA_X_DIM_ID is imported from DimensionalObject (2.40 predates the DimensionConstants extraction). - The unit test mocks CurrentUserService (2.40's service still reads the current user via the injected field) instead of master's static CurrentUserUtil / UserDetails.empty() setup. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../DefaultAnalyticsDimensionService.java | 16 +++- .../DefaultAnalyticsDimensionServiceTest.java | 90 +++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/dimension/DefaultAnalyticsDimensionServiceTest.java diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/dimension/DefaultAnalyticsDimensionService.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/dimension/DefaultAnalyticsDimensionService.java index caf6232b8114..1ae6af15d7ca 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/dimension/DefaultAnalyticsDimensionService.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/dimension/DefaultAnalyticsDimensionService.java @@ -27,6 +27,9 @@ */ package org.hisp.dhis.analytics.dimension; +import static org.hisp.dhis.common.DimensionalObject.DATA_X_DIM_ID; +import static org.hisp.dhis.common.DimensionalObjectUtils.getDimensionFromParam; + import java.util.HashSet; import java.util.List; import java.util.Set; @@ -61,8 +64,19 @@ public class DefaultAnalyticsDimensionService implements AnalyticsDimensionServi @Override public List getRecommendedDimensions(DataQueryRequest request) { - DataQueryParams params = dataQueryService.getFromRequest(request); + // Recommendations are derived solely from the data (dx) dimension. Strip all other + // dimensions (notably ou:LEVEL-*) before resolving, so getFromRequest does not eagerly + // hydrate and sort potentially huge organisation unit collections that are never read. + Set dataDimensionsOnly = + request.getDimension() == null + ? null + : request.getDimension().stream() + .filter(param -> DATA_X_DIM_ID.equals(getDimensionFromParam(param))) + .collect(Collectors.toSet()); + DataQueryParams params = + dataQueryService.getFromRequest( + DataQueryRequest.newBuilder().dimension(dataDimensionsOnly).build()); return getRecommendedDimensions(params); } diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/dimension/DefaultAnalyticsDimensionServiceTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/dimension/DefaultAnalyticsDimensionServiceTest.java new file mode 100644 index 000000000000..7826b49bebc3 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/dimension/DefaultAnalyticsDimensionServiceTest.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2004-2022, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * Neither the name of the HISP project nor the names of its contributors may + * be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.analytics.dimension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.LinkedHashSet; +import java.util.Set; +import org.hisp.dhis.analytics.DataQueryParams; +import org.hisp.dhis.analytics.DataQueryService; +import org.hisp.dhis.common.DataQueryRequest; +import org.hisp.dhis.common.IdentifiableObjectManager; +import org.hisp.dhis.security.acl.AclService; +import org.hisp.dhis.user.CurrentUserService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DefaultAnalyticsDimensionServiceTest { + + @Mock private DataQueryService dataQueryService; + + @Mock private AclService aclService; + + @Mock private CurrentUserService currentUserService; + + @Mock private IdentifiableObjectManager idObjectManager; + + @InjectMocks private DefaultAnalyticsDimensionService service; + + /** + * Recommended dimensions are derived solely from the data (dx) dimension. The request must be + * stripped of all other dimensions (notably ou:LEVEL-*) before it is resolved, so that {@link + * DataQueryService#getFromRequest} never hydrates and sorts potentially huge organisation unit + * collections that this endpoint never reads. + */ + @Test + void getRecommendedDimensionsResolvesOnlyDataDimension() { + Set dimensions = new LinkedHashSet<>(); + dimensions.add("dx:uSw8GwPO417.ACTUAL_REPORTS;uSw8GwPO417.EXPECTED_REPORTS"); + dimensions.add("ou:LEVEL-st3hrLkzuMb"); + dimensions.add("pe:LAST_12_MONTHS"); + + DataQueryRequest request = DataQueryRequest.newBuilder().dimension(dimensions).build(); + + when(dataQueryService.getFromRequest(any())).thenReturn(DataQueryParams.newBuilder().build()); + + service.getRecommendedDimensions(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DataQueryRequest.class); + verify(dataQueryService).getFromRequest(captor.capture()); + + assertEquals( + Set.of("dx:uSw8GwPO417.ACTUAL_REPORTS;uSw8GwPO417.EXPECTED_REPORTS"), + captor.getValue().getDimension(), + "Only the data (dx) dimension should be resolved; ou/pe must be stripped"); + } +}