perf: DataEntry metadata endpoint with fewer n+1s [DHIS2-21757](2.42)#24454
Merged
Conversation
|
Contributor
|
💘 |
david-mackessy
marked this pull request as ready for review
July 17, 2026 05:50
stian-sandvold
approved these changes
Jul 17, 2026
netroms
approved these changes
Jul 20, 2026
vietnguyen
approved these changes
Jul 20, 2026
This was referenced Jul 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



/api/dataEntry/metadata— Multiple N+1 query reduction (2.42)Endpoint:
DataSetMetadataController.getMetadata→DefaultDataSetMetadataExportService.getDataSetMetadata().Problem
The endpoint builds the full data-entry metadata payload (all writable data sets + their data
elements, category combos, category option combos, categories, options, option sets). Several
associations were initialised lazily one primary key at a time, producing large N+1 query
storms. On an implementation production instance the top three query shapes alone were ~86% of all
statements.
Fixes
Fix A — batch the data-element graph (new service method + query)
getDataElementsByDataSet(Collection<DataSet>)loads the data elements of the exported data sets ina single query, eagerly fetching each element's
dataSetElementscollection and itscategoryCombo:Replaces
flatMapToSet(dataSets, DataSet::getDataElements)at the call site. Kills the per-elementdataelementanddatasetelementloads and most of the per-combocategorycomboloads.Files:
DataSetStore,HibernateDataSetStore,DataSetService,DefaultDataSetService,DefaultDataSetMetadataExportService.Fix B — preload category-option-combo options (new service method + query)
Fixes the
categoryoptioncombos_categoryoptionsN+1 —CategoryOptionCombo.getCategoryOptions()loaded once per category option combo.
getCategoryOptionCombosWithCategoryOptions(Collection<CategoryCombo>)primes the session sofield-filter serialization of
categoryOptionCombos[...,categoryOptions~pluck[id]]does not fire aper-combo N+1:
Called for the data-element category combos before the payload is serialized.
Files:
CategoryOptionComboStore,HibernateCategoryOptionComboStore,CategoryService,DefaultCategoryService,DefaultDataSetMetadataExportService.Fixes don't impede on existing ACL checks at the start of the service call for entire scope of feature:
Implementation in production — top-10 query shapes (measured pre-fix)
Pre-fix counts from the implementation's production instance. Not re-measured post-fix — the projected
effect below is inferred from the local dev before/after run, which eliminates the same query shapes.
datasetelement WHERE dataelementid=?(DataElement.dataSetElements inverse)dataelement WHERE dataelementid=?(DataElement proxy per DSE)categoryoptioncombos_categoryoptions WHERE categoryoptioncomboid=?(COC → its category options — the "139 COCs")datasetelement WHERE datasetelementid=?(DSE by PK)categoryoption_organisationunits WHERE categoryoptionid=?categorycombos_categories WHERE categorycomboid=?categorycombo WHERE categorycomboid=?(by PK)categories_categoryoptions WHERE categoryid=?categorycombos_optioncombos WHERE categorycomboid=?userinfo WHERE userinfoid=?(createdBy/lastUpdatedBy)Fixes target #1 + #2 + #3 + #4 (+ part of #7) ≈ 92% of all queries — the implementation's "top three"
plus the DSE-by-PK loads. The local dev run below directly confirms #1, #3 and the category-combo
loads collapse to single queries; #2 and #4 resolve by the same batched-fetch mechanism, so the
implementation's equivalents are expected to resolve equivalently in production.
Not addressed (small individually; would need broader fetch-joins /
@BatchSize, out of scope).Note these are different associations from the COC→category-options join fixed by Fix B (#3),
despite the similar names:
categoryoption_organisationunits— CategoryOption → org unitscategorycombos_categories— CategoryCombo → its categoriescategories_categoryoptions— Category → its category optionscategorycombos_optioncombos— CategoryCombo → its option combosuserinfo— createdBy / lastUpdatedBy per entityLocal dev result — Sierra Leone DB (measured before → after)
Profiled on this branch (
/api/dataEntry/metadata, cold first call after restart), fixes off vs on.These are the numbers we directly measured and can stand behind.
datasetelement WHERE dataelementid=?(COC /getCategoryCombos()inverse)categoryoptioncombos_categoryoptions WHERE categoryoptioncomboid=?(the "139 COCs")categorycombo WHERE categorycomboid=?The two N+1 storms collapse into two single fetch-join queries and
categorycombodrops to the 5data-set combos — ~70% fewer statements, with nothing left that scales per data element / data
set element / category option combo.
After the fixes the endpoint is dominated by two single, non-N+1 queries, out of scope for
batching (data-volume, not query-count):
agg_ou_uidorg-unit associations — 1 execution, ~66 ms.categoryoption→ org units — 1 execution, 4,664 rows, ~60 ms.The residual counts are the expected per-data-set (×26) and per-section (×21) collection loads.
Note on dev vs prod: the standalone
dataelement WHERE dataelementid=?N+1 (implementation prod #2)does not appear on this dev DB — on 2.42 the data elements load inside section join-fetches (see
root-cause note below), so dev cannot demonstrate #2 directly. Fix A still loads them explicitly, and
the implementation's #2 resolves by the same batched-fetch mechanism as #1.
Note on the 2.42-vs-2.44 "one N+1 vs two" difference (root-cause record)
Early profiling showed 2.42 exposing only the
datasetelementloop while 2.44 also exposed thedataelementloop. This was not caching and not a difference in the export code — theexport path is effectively identical. It is a fetch-strategy difference from the hbm→annotation
entity mapping migration:
Section.dataElements(and the operand collections) join-fetch the fullDataElemententity — SQL-confirmed: DataElement columns appear only insidefrom sectiondataelements … inner join dataelement …, never as standalonewhere dataelementid=?.So the entities load inside collection queries and no standalone
dataelementN+1 shows.access loads each
DataElementstandalone → the visibledataelementN+1.Verified with a Hibernate L2 probe (
DataElementregion cold on the first call after restart:miss=601, put=601, hit=0) and startup SQL logs. The N+1 is latent in both versions; whether itmanifests as a standalone loop depends on the mapping's fetch strategy. Either way, the fixes load
everything explicitly and deterministically, independent of mapping/cache behaviour.
Testing
Both fixes have an endpoint-level regression guard (they call
getDataSetMetadata()and countqueries), each verified to fail if its fix is removed:
dataElementCountDoesNotScaleQueryCountcategoryOptionComboOptionsAreNotLoadedPerCombodataEntryMetadataReturnsDistinctDataElementsdataEntryMetadataReturnsCategoryComboOverridesPer-pattern counting is added to
QueryCountDataSourceProxyaggregate counting.Test files:
DataSetMetadataExportServiceQueryCountTest, (dhis-test-integration);DataSetMetadataControllerTest(+2 methods) anddataset/dataentry_metadata_query_coverage.jsonfixture (dhis-test-web-api);net.ttddyy:datasource-proxytest dependency added todhis-test-integration/pom.xml.** Manual testing of the endpoint response against SL DB produced the same json response data (before v after)
I tried to replicate the DB/query shape from the production implementation. It's almost impossible to get like for like but this is as close as I got. The main culprits were reproducible and are gone. 42-dev v fix branch