Skip to content

fix: stop tracker preheat from fully materializing OptionSet.options (master port of #24813) - #24815

Merged
netroms merged 6 commits into
masterfrom
fix/optionset-preheat-n1-master
Aug 11, 2026
Merged

fix: stop tracker preheat from fully materializing OptionSet.options (master port of #24813)#24815
netroms merged 6 commits into
masterfrom
fix/optionset-preheat-n1-master

Conversation

@jason-p-pickering

@jason-p-pickering jason-p-pickering commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Master port of #24813 (still open against 2.41, developed there first because a
production-scale database was available to gather evidence). Same fix, same design — see
that PR for the original rationale. This PR adapts the
change for master's structural divergence since 2.41:

  • dhis-service-tracker module → dhis-tracker (package unchanged)
  • Event became an interface (TrackerEvent/SingleEvent); preheat/rule
    code here uses TrackerEvent
  • Program rule executors' data element/attribute identifiers are now
    UID-typed rather than String
  • TrackerIdSchemeParam moved from org.hisp.dhis.tracker.imports to
    org.hisp.dhis.tracker
  • TrackerEvent/TrackedEntity/Enrollment builders now require a
    non-null UID identifier field — this caught a real bug during porting
    (see Testing below)

Fix

Tracker import preheat maps every referenced OptionSet through
OptionSetMapper. The generated MapStruct code called .size() on the
lazy Hibernate collection backing OptionSet.options, forcing a full
materialization of every Option row for that set — including JSONB
attributevalues deserialization per row — regardless of how many option
codes the import payload actually references.

  • TrackerPreheat gains addValidOptionCode/isValidOptionCode and
    addResolvedOptionSet/isOptionSetResolved, storage for confirmed-valid
    (option set, code) pairs.
  • A new OptionValueSupplier preheat supplier walks the payload's
    attribute/data values, collects only the option codes actually
    referenced (splitting multi-text values), and resolves them with one
    batched, chunked JDBC query against optionvalue — cost bounded by
    payload width, not option set size.
  • ValidationUtils.validateOptionSet reads from that preheated data
    instead of OptionSet.getOptions().
  • OptionSetMapper stops mapping options entirely — the expensive
    collection load never happens during preheat.
  • Program rule ASSIGN actions can introduce option-set values that were
    not in the original payload. Since those values are the rule engine's
    already-evaluated output, they're resolved the same way, right after the
    rule engine runs and before any validation reads them.

Performance validation (master, this PR)

Companion PR #24814 adds a dedicated SingleEventTest.java Gatling harness (a fourth
Sierra Leone demo program, "Inpatient morbidity and mortality," using a ~14,000-option
ICD-10 diagnosis option set) specifically to exercise this code path, and dispatched
performance-tests-compare.yml against this PR directly: baseline dhis2/core-dev:latest
(unpatched master) vs candidate dhis2/core-pr:24815 (this branch).

SMOKE profile (1 user, 10 sequential single-event imports):

Baseline (unpatched) Candidate (this PR)
p95 1,204 ms 105 ms

LOAD profile (20 concurrent users, 50 imports each — real concurrent single-event
traffic, matching how single-event programs are actually imported in production: one
event per request, from many independent sessions):

Baseline (unpatched) Candidate (this PR)
p95 32,183 ms 666 ms
Success rate 99.7% 100%
Failure mode persistent trickle of hard 60s client-timeout failures — some requests never completed at all, not just slow none

That's a ~48x improvement in p95 under load, and — independent of the latency number —
the fix eliminates outright request failures under concurrency, not just slowness.
Both runs are unconfounded: SingleEventTest.java is fully self-contained and shares no
configuration with any other scenario, so these numbers measure exactly what they claim to.

Testing

  • Unit tests for TrackerPreheat, OptionValueSupplier, and
    ValidationUtils's three validator call sites, including multi-text and
    program-rule-assign coverage — all ported and adapted from perf: stop Tracker preheat from fully materializing OptionSet.options [41] #24813.
  • Real end-to-end integration tests (EventImportValidationTest,
    TrackedEntityImportValidationTest, EnrollmentAttrValidationTest,
    ProgramRuleAssignActionTest) re-run after every behavioral change,
    confirming no regression to E1125 semantics.
  • The integration run caught a real bug the port introduced: the synthetic
    TrackerEvent/Enrollment objects built to re-resolve ASSIGN-action
    values were missing their now-mandatory .event(UID)/.enrollment(UID)
    identifiers, which compiled fine (Lombok's @NonNull is runtime-only)
    but NPE'd at import time. Fixed before opening this PR.
  • All 199 affected tests pass; spotless:apply run on the full diff.
  • A SonarQube visibility-modifier nit (java:S5786, redundant public on a
    JUnit5 @BeforeEach method) was fixed in a follow-up commit.
  • Real baseline-vs-candidate performance validation against master, via test: add Inpatient morbidity/ICD-10 tracker import perf scenario #24814's dedicated
    SingleEventTest.java — see also comment on TrackerTest below.

🤖 AI Assisted

Adds per-option-set valid-code and resolved-set tracking so validators
and the ASSIGN-action pipeline can check option codes without
materializing the full OptionSet.options collection.
Queries optionvalue directly for the (option set, code) pairs actually
referenced by the import payload, in chunks, instead of relying on
OptionSetMapper to hydrate entire option collections. Cost is bounded
by distinct codes referenced, not option set size.

Includes the NOSONAR suppression for the two-phase collect/query loop
(false positive: confirmed is populated by the query immediately
before it's read).
Switches validateOptionSet() to check TrackerPreheat's valid-code cache
instead of scanning OptionSet.getOptions(), with a diagnostic log.warn
when an option set never got resolved during preheat. Updates all
three call sites (tracked entity, enrollment, and event attribute/data
value validators) and their tests accordingly.
OptionSetMapper no longer maps the options collection, which was
forcing a full lazy-collection load (and JSONB deserialization of
every option) for every OptionSet touched during tracker preheat.
Callers now go through OptionValueSupplier/TrackerPreheat instead.
ASSIGN actions can introduce data element/attribute values that were
never part of the original import payload, so OptionValueSupplier's
single preheat pass can't have seen their option codes. Runs the
assigned values back through OptionValueSupplier as a synthetic
TrackerObjects before validation, so option-set validation still sees
them as preheated.

Adds getter access to the executors' resolved data element/attribute
UID and value, and asserts no errors in the previously-unchecked
option-value ASSIGN warning test.
@jason-p-pickering
jason-p-pickering requested a review from a team as a code owner August 8, 2026 19:38
@jason-p-pickering jason-p-pickering added the run-perf-tests Enables performance tests label Aug 8, 2026
…orTest

SonarQube java:S5786 -- JUnit5 lifecycle methods don't need public visibility.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Aug 9, 2026

Copy link
Copy Markdown

@jason-p-pickering

Copy link
Copy Markdown
Contributor Author

No regression on the existing TrackerTest (SMOKE, master vs this PR)

Ran performance-tests-compare.yml against org.hisp.dhis.test.tracker.TrackerTest unmodified -- no knob changes, same class as on master -- SMOKE profile:

  • baseline: dhis2/core-dev:latest (master)
  • candidate: dhis2/core-pr:24815 (this PR)

The candidate step ran with Gatling's default failOnError=true (only the baseline step disables it, to allow tightening thresholds later) and the job passed, meaning candidate met every one of TrackerTest's own built-in p95 assertions, including the import paths this fix touches:

Request baseline p95 candidate p95 threshold
MNCH import 89ms 89ms 140ms
Child Programme import 64ms 63ms 115ms
ANC import 42ms 39ms 71ms

Flat to slightly faster on all three imports. The export/browse requests (ANC events, Child Programme TE search, etc.) show some percentage swings, but those are single-digit-ms deltas on 5-47 samples at SMOKE's single-user concurrency -- noise at that sample size, and none approach their own thresholds.

Full comparison table:

gstat compare, all requests
Requests baseline p95 candidate p95 Diff (ms) Change
Get ANC events / Get one event / Get first event 16 16 -0 -0.3%
Get ANC events / Get one event / Get relationships for first event 5 5 +0 +0.0%
Get ANC events / Go to first page 16 17 +1 +6.2%
Get ANC events / Go to second page 18 18 +0 +0.0%
Get ANC events / Search not assigned 16 17 +1 +5.9%
Get ANC events / Search by date range 25 28 +3 +12.2%
Get Child Programme TEs / Go to single enrollment / Get one event / Get first event from enrollment 26 25 -1 -3.8%
Get Child Programme TEs / Go to single enrollment / Get one event / Get relationships for first event 5 5 +0 +0.0%
Get Child Programme TEs / Go to single enrollment / Get first tracked entity 19 27 +8 +42.1%
Get Child Programme TEs / Go to single enrollment / Get first enrollment 7 7 +0 +0.0%
Get Child Programme TEs / Go to single enrollment / Get relationships for first tracked entity 5 6 +1 +20.0%
Get Child Programme TEs / Not found TE by name with like operator 8 8 +0 +0.0%
Get Child Programme TEs / Not found TE by name with eq operator 6 6 +0 +0.0%
Get Child Programme TEs / Search TE by name with like operator 29 34 +5 +17.4%
Get Child Programme TEs / Search TE by name with eq operator 23 28 +5 +22.0%
Get Child Programme TEs / Search Birth events 33 30 -3 -9.1%
Get Child Programme TEs / Get TEs from events 9 10 +1 +11.1%
Get Child Programme TEs / Get first page of TEs 35 36 +1 +3.0%
Get Child Programme TEs / Get TEs with enrollment status 44 47 +3 +6.8%
Login 102 118 +16 +15.9%
MNCH import 89 89 -0 -0.4%
Child Programme import 64 63 -1 -1.2%
ANC import 42 39 -3 -7.9%

Bottom line: this PR produces no regression on the existing, unmodified TrackerTest -- all built-in assertions pass, import p95s flat-to-improved. (Companion perf coverage for the specific option-set-heavy scenario this fix targets is in #24814 / SingleEventTest, which shows the larger effect under a big option set and concurrent load.)

@jason-p-pickering

Copy link
Copy Markdown
Contributor Author

Relationship to the L2 cache "lock storm" work (#24803, #24810) — complementary, not a substitute

There's a separate, much more rigorous investigation into a Hibernate L2 cache "lock storm"
affecting option-heavy tracker imports (Morten's Phase 4 report, 2026-08-08, n=3 per cell,
async-profiler-backed, deterministic per-defect isolation). That report pins the lock storm
on three specific, unrelated defects — wholesale cache wipes from a native executeUpdate
without synchronized query spaces (fixed by #24803), a per-region rather than per-key lock in
READ_WRITE access, and store-by-value copying inside that lock (both addressed by #24810's
NONSTRICT + predefined-region approach). This PR was never one of the tested arms in that
ladder
, and an earlier version of this description overstated the connection by implying
our own load-test results "confirmed" that same mechanism. They don't, on their own — a
head-to-head comparison clarifies the actual relationship:

Same SingleEventTest LOAD scenario (20 concurrent users), same run, three builds:

Build Success rate p95
Unpatched master 99.7% 32,183 ms (persistent 60s client-timeout failures)
#24803 only (wipe fix, no OptionSet fix) 100% 18,568 ms
#24815 only (this PR, no wipe fix) 100% 503 ms

Both fixes independently eliminate master's hard-timeout failure mode — consistent with
some overlap, since a much cheaper OptionSet reload (this PR) plausibly also shortens how
long any given lock is held during a wipe-triggered reload storm, even though it doesn't
touch wipe cadence, lock granularity, or copy semantics at all. But on raw latency, this PR
alone beats the wipe fix alone by ~37x (503ms vs 18,568ms), despite the wipe-fix build
still carrying all three of Morten's defects. That's better explained by this PR fixing a
different, largely independent bottleneck — the direct N+1 query cost and per-row JSONB
deserialization described above — than by it duplicating #24803/#24810's mechanism.

Practical takeaway: these are two separate, complementary fixes for the same option-heavy
workload, not competing explanations for the same number. We haven't tested them combined —
worth doing before assuming they simply stack, but nothing here suggests they'd conflict.

@netroms
netroms merged commit 51d94d1 into master Aug 11, 2026
25 checks passed
@netroms
netroms deleted the fix/optionset-preheat-n1-master branch August 11, 2026 05:44
@jason-p-pickering
jason-p-pickering restored the fix/optionset-preheat-n1-master branch August 11, 2026 08:12
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.

3 participants