Skip to content

perf: project program stage data elements in preheat - #24773

Draft
teleivo wants to merge 5 commits into
masterfrom
psde-projection-master
Draft

perf: project program stage data elements in preheat#24773
teleivo wants to merge 5 commits into
masterfrom
psde-projection-master

Conversation

@teleivo

@teleivo teleivo commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Draft. Tests are still outstanding, see the end.

The issue

Uganda run a custom capture app that batches up entries and syncs them through /api/tracker. On 2.42.5.1, at ~200 RPS on tracker import, requests stall: a single import trace shows 82s of wall time for 1.3s of CPU, with ~89% of samples parked on a lock.

What makes their instance unusual is the metadata, not the payload. Their trace shows 1281 data element loads in a single import, against 3 to 50 data elements per stage in the Sierra Leone demo programs. A payload referencing a handful of data values pays for all of them.

That is because preheat maps Program through MapStruct, which walks ProgramStage.programStageDataElements and dereferences each element's dataElement. Nothing batches this, Hibernate's hibernate.default_batch_fetch_size is unset repo-wide so it defaults to off, so it is one entity load per data element. The queries themselves are fast, 1.039s for all 1281, so the DB is not the problem.

The problem is what each of those loads touches. DataElement.hbm.xml declares <cache usage="read-write"/>, and in Hibernate 5.6 that strategy is served by AbstractReadWriteAccess, the only class in cache/spi/support holding a ReentrantReadWriteLock. That lock is per region, not per key, there is no striping, so every DataElement key serializes through one lock. Reads share it, but every cache miss takes it exclusively and blocks readers of every other data element. It is also the non-fair constructor, so a reader arriving while a writer is merely queued parks behind it rather than barging.

Multiply that by the N+1 (1281 acquisitions per request) and by concurrent imports and the region never returns to the cheap shared state. Threads spend their time parked rather than working, which is why the trace shows 82s of wall against 1.3s of CPU.

Where the time goes

Wall samples under TrackerImportController, from profile.collapsed of the profiled run below.

frame before after
ProgramStageDataElement.hashCode (triggers the proxy init) 60.9% 0.0%
AbstractReadWriteAccess (L2 region lock) 57.9% 0.3%
Unsafe.park (threads parked on that lock) 47.6% 0.0%
loadFromDatasource 19.0% 0.3%
ProgramStageDataElementsSupplier (the new query) -- 0.1%

These overlap rather than sum, they are nested frames. The lock is gone and the replacement query does not take its place.

Fix

Only three call sites read programStageDataElements during import, and all three need an identifier and nothing else:

site needs
DataValuesValidator E1303, E1076 compulsory data elements of the stage
DataValuesValidator E1305 is a payload data element part of the stage
RuleActionEventMapper is a rule effect target part of the stage

The last two are membership tests whose probes are known up front, so a data element that neither the payload nor the rules mention cannot change the outcome and does not need to be fetched.

So drop @Mapping(target = "programStageDataElements") and add a dedicated preheat supplier, ProgramStageDataElementsSupplier, which fetches all of it in one JDBC query instead of one Hibernate entity load per data element. That removes the N+1 and, because it does not go through Hibernate, it never touches the L2 cache or its region lock:

select psde.programstageid, psde.compulsory, de.uid, de.code, de.name, de.attributevalues
from programstagedataelement psde
join dataelement de on de.dataelementid = psde.dataelementid
where psde.programstageid in (:stageIds) and psde.compulsory
union
select psde.programstageid, psde.compulsory, de.uid, de.code, de.name, de.attributevalues
from programstagedataelement psde
join dataelement de on de.dataelementid = psde.dataelementid
where psde.programstageid in (:stageIds) and de.uid in (:dataElementUids)

Bounded by |compulsory| + |payload + rules| rather than by the width of the stage. The compulsory branch still scales with the program, so the gain shrinks the more of a stage is compulsory. The widened program measured below has none, and Uganda's compulsory count is not known.

Unioned rather than ored: an or spanning both tables cannot use an index, so Postgres reads every row of the stage and of dataelement and filters after. Split, the second branch uses dataelement_uid_key.

Data elements referenced by the payload or by program rules are still loaded as full entities by DataElementStrategy, since validation needs their valueType and optionSet. That path is untouched, and it is a single batched IN query rather than the per-element loads removed here. What goes away is loading the stage's other data elements, the ones no payload or rule mentions.

Sidenote: tracker's own preheat cache has been hardcoded off for years (DefaultPreheatCacheService.isCacheEnabled() returns false, config read commented out), so tracker.import.preheat.cache.enabled is dead config. Worth revisiting separately: caching a few things explicitly, with a deliberate TTL and without Hibernate's per-entity serialization, looks more promising than relying on L2 for this.

Performance

TrackerTest was adapted to attach ~2000 extra data elements to the MNCH / PNC program stages before the run, to get within range of Uganda's 1281 loads per import. Stock Sierra Leone programs are far too narrow to reproduce this. See TrackerTest#widenMnchProgram on d725da3.

32 concurrent import users, 180s, Sierra Leone DB. Zero failed requests on both sides of both runs.

  • Load run -- no profiler, the numbers below
  • Profiled run -- -e wall, the source of the frame table above

Baseline: master @ e47c009
Run 2: this PR

Median Response Time (p50) (ms)

Requests baseline req/s projection req/s Diff (ms) Change
MNCH import 10,810 2.61 4,686 6.06 -6,124 ⬇️ -56.6%

95th Percentile Response Time (p95) (ms)

Requests baseline req/s projection req/s Diff (ms) Change
MNCH import 20,663 2.61 9,613 6.06 -11,050 ⬇️ -53.5%

Throughput 2.61 -> 6.06 req/s, 492 -> 1118 imports in the same window.

The gain scales with how many data elements the program stages carry. On a stock Sierra Leone program there is nothing to remove; this only pays off on wide programs like Uganda's.

Still to do

  • a test that a program rule effect targeting a stage data element absent from the payload still fires
  • this is on master to get a working perf comparison; 2.42 needs #24756, where user replication does not copy org units so the load test cannot provision usable users

Mapping ProgramStage.programStageDataElements initialized one DataElement
entity per element, thousands on wide programs, each taking the L2 cache
region lock. Only three call sites read that association and all need just
an identifier: compulsory data elements (E1303, E1076), payload membership
(E1305) and program rule effect membership.

Drop the mapping and project the needed data elements with one query per
preheat instead, bounded by the compulsory count plus payload and rule
width rather than by program width.
teleivo and others added 4 commits August 14, 2026 15:10
The rule engine runs after validation and DataValuesValidator runs again
afterwards, on the payload an ASSIGN effect mutated. Such an effect can add a
data value for a data element the original payload did not carry, so the
projected data elements of the stage have to cover it or that second pass
rejects valid data with E1305.

They do, because TrackerIdentifierCollector preheats the data elements of all
program rule actions, which is what ProgramStageDataElementsSupplier probes
with. Nothing pinned that invariant, so narrowing the collection to the
payload's programs would have broken the projection silently.
Query the two id lists as arrays with = any() instead of in (). An empty in ()
list is not valid SQL, which needed a sentinel uid that could never match. An
array is well formed when empty and still uses dataelement_uid_key, so the
sentinel is gone. It also keeps the statement text independent of the payload
width, where in (...) expands to one placeholder per element.

Name the stage collections after program stages rather than stages, drop the
benchmark numbers and what the supplier already explains from the surrounding
javadoc, and split the compulsory data element codes: E1303 reports one missing
from the event, E1076 one the event deletes.
The supplier claimed the payload and program rule data elements are both
preheated. That only holds under idScheme=UID: TrackerIdentifierCollector adds
the rule ones as uids but into the same identifier set as the payload's, which is
queried in the payload's idScheme, so under CODE, NAME or ATTRIBUTE they never
load and silently drop out of the probe set.

Also say that memberUids holds the same data elements as members rather than
implying it is a separate selection.
@jason-p-pickering jason-p-pickering added the run-perf-tests Enables performance tests label Aug 14, 2026
@sonarqubecloud

Copy link
Copy Markdown

@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.32258% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 55.05%. Comparing base (e15b4d9) to head (02c10a8).
⚠️ Report is 14 commits behind head on master.

Files with missing lines Patch % Lines
...eat/supplier/ProgramStageDataElementsSupplier.java 87.75% 5 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #24773      +/-   ##
============================================
- Coverage     58.05%   55.05%   -3.01%     
+ Complexity     1414      327    -1087     
============================================
  Files          3725     3729       +4     
  Lines        144739   144958     +219     
  Branches      16875    16897      +22     
============================================
- Hits          84033    79806    -4227     
- Misses        53550    57611    +4061     
- Partials       7156     7541     +385     
Flag Coverage Δ
integration 47.65% <90.32%> (+7.87%) ⬆️
integration-h2 28.12% <3.22%> (?)
unit ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...s/tracker/imports/config/TrackerPreheatConfig.java 100.00% <ø> (ø)
...cker/imports/preheat/ProgramStageDataElements.java 100.00% <100.00%> (ø)
...p/dhis/tracker/imports/preheat/TrackerPreheat.java 84.84% <100.00%> (-4.89%) ⬇️
...er/imports/preheat/mappers/ProgramStageMapper.java 100.00% <ø> (ø)
...ker/imports/programrule/RuleActionEventMapper.java 71.87% <100.00%> (+71.87%) ⬆️
.../imports/validation/validator/ValidationUtils.java 66.21% <ø> (-11.03%) ⬇️
...alidation/validator/event/DataValuesValidator.java 90.47% <100.00%> (-7.40%) ⬇️
...eat/supplier/ProgramStageDataElementsSupplier.java 87.75% <87.75%> (ø)

... and 1868 files with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 0fe439e...02c10a8. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-perf-tests Enables performance tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants