Fix/optionset preheat n1 master - #24850
Conversation
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.
…orTest SonarQube java:S5786 -- JUnit5 lifecycle methods don't need public visibility. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
|
||
| private static final String CODE = "code"; | ||
|
|
||
| private static final String SQL = |
There was a problem hiding this comment.
Just wondering if here would be worth it to query directly the pairs we need, something like
select optionsetid, code from optionvalue
where (optionsetid || ':' || code) in (:pairs);
pairs being the objects in the chunk list
There was a problem hiding this comment.
Good point. Let me check.
There was a problem hiding this comment.
I looked at it quickly, and I do not think so. Ran both plans against the Sierra Leone DB.
EXPLAIN ANALYZE SELECT optionsetid, code from optionvalue
where (optionsetid::text || ':' || code) in ('3150104' || ':' || 'B023');
Seq Scan on optionvalue (cost=0.00..646.34 rows=76 width=13) (actual time=0.159..2.428 rows=1 loops=1)
Filter: ((((optionsetid)::text || ':'::text) || (code)::text) = '3150104:B023'::text)
Rows Removed by Filter: 15214
Execution Time: 2.438 ms
vs. the current shape:
EXPLAIN ANALYZE SELECT optionsetid, code from optionvalue
where optionsetid in (3150104) and code in ('B023');
Index Only Scan using optionvalue_unique_optionsetid_and_code on optionvalue (cost=0.29..4.30 rows=1 width=13) (actual time=0.012..0.012
rows=1 loops=1)
Index Cond: ((optionsetid = 3150104) AND (code = 'B023'::text))
Heap Fetches: 0
Execution Time: 0.022 ms
optionsetid::text || ':' || code has no matching expression index, so Postgres has to sequential-scan and evaluate the concatenation on every row of optionvalue . The proposed alternative route is more than 100 times slower.
The two separate INs aren't an approximation of the pair match either. They're sargable via optionvalue_unique_optionsetid_and_code (the UNIQUE (optionsetid, code) constraint from V2_41_6__Unique_code_within_each_optionset.sql), and we still get an exact pair match, just done in memory.
One caveat worth flagging: that unique constraint is created conditionally by the 2.41 migration. It's skipped on any instance that already had duplicate (optionsetid, code) rows at upgrade time. On such an instance neither query shape gets the index, and we're back to a seq scan regardless.
There was a problem hiding this comment.
@muilpp I added a regression test to guarantee correctness.
The test mocks a database containing (1,"B00"), (2,"A00"), (3,"C00") while the payload asks about (1,"A00"), (2,"B00"), (3,"C00"). Every requested optionSetId and every requested code shows up in some row, but the fabricated pairings never co-occur. The test asserts the two fabricated pairs stay invalid and the genuine one still validates.
I also performed a mutation test to ensure that it was not vacuous.
Hope this helps to address your concerns about the query pattern, while still showing why it should be this way due to the index.
There was a problem hiding this comment.
These forms query the pairs we need directly while allowing the use of the optionvalue_unique_optionsetid_and_code index
-- values join
join (values (3150104,'B023'), (3000008,'MODDISCH')) as t(optionsetid, code)
on o.optionsetid = t.optionsetid and o.code = t.code
-- unnest of parallel arrays
join unnest(:setids::bigint[], :codes::text[]) as t(optionsetid, code)
on o.optionsetid = t.optionsetid and o.code = t.code@ameenhere is it worth benchmarking the 3 different forms that can use the index in Uganda? Maybe @jason-p-pickering can also test them in implementations you have access to? Uganda does bulk payloads with a single program. I think DEs/Attributes are in the hundreds but not sure. Ameen might know better.
There was a problem hiding this comment.
I think it is worth exploring direct SQL rather than in-memory mechanisms. The approach here has a cost relative to the payload size (in addition to the metadata size). And that makes me uncomfortable because I know that can cause a lot of variations in performance from implementation to implementation.
The import payloads in Uganda was not "small". On an average they had 10s/100s of TEs, with maybe twice/thrice the events. And each event potentially having average 500 (and maybe upto 1000) DEValues. So that payload would cause unnecessary overhead with this approach. I also know there is a WHO implementation that wants to import huge payload (once a year procedure), payload file potentially reaching a GB or so. Not saying that huge payload approach is ideal, but reminding ourself how implementation nature can vary a lot.
Note that in the query comparisons above, we are only looking at the "gain" on the DB side. So we have no idea how the in-memory filtering affects the overall e2e performance.
That said, if we are able to conclude on a direct SQL mechanism that has an acceptable performance (perhaps the ones that @teleivo suggested above?), that is definitely preferred.
WDYT @jason-p-pickering ?
There was a problem hiding this comment.
Last comment here...GB payloads should be rejected by the reverse proxy (usually 100 MB max). I am assuming you mean when unzipped. That sounds however like an extremely bad idea. I do not think its a use case we should code for.
There was a problem hiding this comment.
Last comment here...GB payloads should be rejected by the reverse proxy (usually 100 MB max)
I don't think they are importing a GB at once @ameenhere do they? I think they are chunking it but of course want to import that giant data set as fast as possible.
…ion-set pair The two-column IN/IN query in queryChunk() can return rows whose optionsetid and code each satisfy one of the two independent IN-lists without the pair itself ever having been requested. Correctness depends entirely on the in-memory confirmed.contains(pair) check filtering those out before anything is marked valid. Add a regression test that mocks a query result covering that exact cross-contamination shape and confirms the fabricated pairs stay unvalidated while a genuine pair still passes; verified by temporarily weakening the check and watching this test fail.
| if (!allCodesValid) { | ||
| if (!preheat.isOptionSetResolved(optionSetId)) { | ||
| // Diagnostic only, the validation outcome below is unchanged. An unresolved option set | ||
| // means the supplier never checked it, which is an internal bug rather than user error. |
There was a problem hiding this comment.
What would cause such a bug?
There was a problem hiding this comment.
Traced the pipeline (create() → preheat → validate → runRuleEngine() → validate again) and the value-mutating action types (ProgramRuleActionType.IMPLEMENTED_ACTIONS = {SENDMESSAGE, SCHEDULEMESSAGE, ASSIGN}). Of those, ASSIGN is the only one that touches a data value/attribute, and collectRuleAssignedValues() covers it exhaustively. So I can't currently construct a path that reaches that branch. It's a canary, not a known hole. It would fire if, a future PR adds a new value-mutating action type and forgets to teach OptionValueSupplier about it, or if the preheat ordering (ClassBasedSupplier → OptionValueSupplier → ...) gets reordered. Happy to drop it if you'd rather not carry defensive logging for a case that's unreachable today. Your call.
| public TrackerBundle runRuleEngine(@Nonnull TrackerBundle trackerBundle) { | ||
| programRuleService.calculateRuleEffects(trackerBundle, trackerBundle.getPreheat()); | ||
|
|
||
| optionValueSupplier.preheatAdd( |
There was a problem hiding this comment.
This is more about the architecture / code placement. I am not sure this is the best place. I think @enricocolasante who is back next week might have a better answer than me with regards to rule engine.
If the fix is urgent we could merge it and Enrico and myself can debate on where to move it later on.
There was a problem hiding this comment.
It's a fair flag. I think ( I could be wrong) this is the earliest point which I could find where the rule engine's ASSIGN existed to preheat. Happy to wait until @enricocolasante gets back, but considering that we have seen a 5-6x drop in production numbers, I would lean towards merging it and revisiting.
|
|
||
| private static final String CODE = "code"; | ||
|
|
||
| private static final String SQL = |
There was a problem hiding this comment.
These forms query the pairs we need directly while allowing the use of the optionvalue_unique_optionsetid_and_code index
-- values join
join (values (3150104,'B023'), (3000008,'MODDISCH')) as t(optionsetid, code)
on o.optionsetid = t.optionsetid and o.code = t.code
-- unnest of parallel arrays
join unnest(:setids::bigint[], :codes::text[]) as t(optionsetid, code)
on o.optionsetid = t.optionsetid and o.code = t.code@ameenhere is it worth benchmarking the 3 different forms that can use the index in Uganda? Maybe @jason-p-pickering can also test them in implementations you have access to? Uganda does bulk payloads with a single program. I think DEs/Attributes are in the hundreds but not sure. Ameen might know better.
|
(I asked Claude Code to run this benchmark and write up the results below.) Ran all three forms against a local copy of the SL demo DB (PostgreSQL 14.23) — it happens to already contain the exact pair from the example ( Index confirmed in place and table What's actually happening with the current select optionsetid, code from optionvalue
where optionsetid in (...51 ids...)
and code in (...546 codes...)Both the VALUES and UNNEST forms compile to a select o.optionsetid, o.code from optionvalue o
join unnest(:setids::bigint[], :codes::text[]) as t(optionsetid, code)
on o.optionsetid = t.optionsetid and o.code = t.code(VALUES gives the identical plan shape — Timings across scenarios, pairs scaled from the literal 2-pair example up to a 550-pair / 51-option-set stress case:
Even on a small 15K-row demo table the gap grows with cardinality; against real option sets larger than 14K rows, and at the code's actual chunk size ( Both VALUES and UNNEST give an equivalent, scale-stable plan. I'd lean UNNEST for the implementation — it binds two arrays instead of building a literal |
|
(Drafted with Claude Code's help, at my request.) Thanks for pushing on the query-plan detail, @ameenhere . It's a fair thing to want nailed down, and I'm glad to switch the While we're calibrating priority, I wanted to add one more data point alongside the Gatling numbers already in #24815/#24814: a production instance running this patch. Average tracker request time, 5-minute buckets,real traffic (screenshot attached below):
Before the patch: bouncing 700ms–1.75s per request, a lot of it sitting around 1.0–1.5s. After deploy (~Aug 9): flat ~200–300ms, and it's held there for four days of real traffic since — roughly a 5-6x drop in real average tracker import latency under production load. That's consistent with what we already had:
Sharing this mainly so we're weighing the SQL-form choice against the right backdrop. It's a good implementation detail to get right, but it's not gating whether the underlying fix is worth landing. Let me know which form you'd like for the query and I'll get it updated. |
|
Thanks for exploring that @jason-p-pickering Let us go for the UNNEST variant then and get this PR merged 👍 I am approving this PR in advance, so that you don't have to wait further after making the last change. All other findings (and future findings) will be followup tasks for us, tracker team. |
|
Sounds good.. I'll fix and provide supporting perf results. |
Switches the (optionsetid, code) lookup from IN (...) AND IN (...) to a join against unnest(?::bigint[], ?::text[]), per @teleivo/@ameenhere's review on #24850. The IN/IN form only sargs on the leading index column once cardinality grows, downgrading `code` to a post-scan Filter; the unnest join keeps both columns in the Index Cond via optionvalue_unique_optionsetid_and_code regardless of scale.
|
|
(Drafted with Claude Code's help, at my request.) Ran Smoke (1 user, 10 requests):
~8.6x faster on p95. Load (20 concurrent users, 50 requests each = 1000 imports):
~25.6x faster on p95, ~35.7x on mean. Every request on master takes multiple seconds under this concurrency (several over 20s); the fix stays under a second throughout. Runs:
Raw artifacts (Gatling reports, gh run download 31796009053 --repo dhis2/dhis2-core \
--name gatling-report-compare-singleevent-smoke-24850-31796009053-attempt-1 \
--dir ./compare-singleevent-smoke-24850
gh run download 31796033858 --repo dhis2/dhis2-core \
--name gatling-report-compare-singleevent-load-24850-31796033858-attempt-1 \
--dir ./compare-singleevent-load-24850 |
… [2.42 port] Ports #24850's fix from master to 2.42: preheat only the (option set, code) pairs actually referenced by an import payload via a new OptionValueSupplier, instead of OptionSetMapper forcing Hibernate to fully materialize every touched OptionSet.options collection (including JSONB attributevalues deserialization) regardless of how many option codes the import actually references. 2.42 predates master's tracker/single-event domain split, so the ported code uses 2.42's Event domain class instead of TrackerEvent. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Preheat only the (option set, code) pairs actually referenced by an import payload, via a new OptionValueSupplier that queries optionvalue directly (unnest parallel-array join), instead of relying on OptionSetMapper to hydrate entire OptionSet.options collections (including per-row JSONB attributevalues deserialization) regardless of how many option codes the import actually references. ValidationUtils.validateOptionSet reads from TrackerPreheat's targeted (option set, code) cache instead of OptionSet.getOptions(). Program rule ASSIGN actions are resolved through the same supplier right after calculateRuleEffects, since they can introduce option-set values that were never in the original payload. Verified functionally equivalent to master's #24850, file by file, with 2.41's own type conventions kept where master has since diverged (String ids predating UID, javax.persistence.EntityManager, etc.). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… [2.42 port] (#24871) Ports #24850's fix from master to 2.42: preheat only the (option set, code) pairs actually referenced by an import payload via a new OptionValueSupplier, instead of OptionSetMapper forcing Hibernate to fully materialize every touched OptionSet.options collection (including JSONB attributevalues deserialization) regardless of how many option codes the import actually references. 2.42 predates master's tracker/single-event domain split, so the ported code uses 2.42's Event domain class instead of TrackerEvent. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat: add valid-option-code storage to TrackerPreheat 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. * feat: add OptionValueSupplier, preheat only referenced option codes 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). * feat: validate option codes against preheated OptionValueSupplier data 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. * perf: stop preheat from fully materializing OptionSet.options 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. * fix: resolve option codes introduced by program rule ASSIGN actions 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. * test: remove redundant public visibility modifier in AttributeValidatorTest SonarQube java:S5786 -- JUnit5 lifecycle methods don't need public visibility. * test: prove OptionValueSupplier can't validate a fabricated cross-option-set pair The two-column IN/IN query in queryChunk() can return rows whose optionsetid and code each satisfy one of the two independent IN-lists without the pair itself ever having been requested. Correctness depends entirely on the in-memory confirmed.contains(pair) check filtering those out before anything is marked valid. Add a regression test that mocks a query result covering that exact cross-contamination shape and confirms the fabricated pairs stay unvalidated while a genuine pair still passes; verified by temporarily weakening the check and watching this test fail. * fix: use unnest parallel-array join in OptionValueSupplier Switches the (optionsetid, code) lookup from IN (...) AND IN (...) to a join against unnest(?::bigint[], ?::text[]), per @teleivo/@ameenhere's review on #24850. The IN/IN form only sargs on the leading index column once cardinality grows, downgrading `code` to a post-scan Filter; the unnest join keeps both columns in the Index Cond via optionvalue_unique_optionsetid_and_code regardless of scale. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
… [41] (#24813) * feat: add valid-option-code storage to TrackerPreheat * feat: add OptionValueSupplier, preheat only referenced option codes Adds a new tracker preheat supplier that computes the (option set, code) pairs actually referenced by an import payload and confirms them against the optionvalue table, instead of relying on OptionSetMapper to fully materialize every OptionSet.getOptions() collection. Wired into TrackerPreheatConfig.preheatOrder immediately after ClassBasedSupplier, since it depends on DataElement/TrackedEntityAttribute already being preheated. Not yet consumed by validation - ValidationUtils still reads .getOptions() until a follow-up task switches it over to TrackerPreheat.isValidOptionCode(). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat: validate option codes against preheated OptionValueSupplier data Switch ValidationUtils.validateOptionSet to read option-code validity from TrackerPreheat.isValidOptionCode(optionSetId, code) instead of iterating optionSet.getOptions(), avoiding the N+1 tracker import query. OptionSetMapper's options mapping is left in place for now (removed in a follow-up task). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * perf: stop preheat from fully materializing OptionSet.options OptionSetMapper's `options` mapping forced Hibernate to fully load the OptionSet.options collection (including JSONB attribute values) on every preheat, regardless of how many option codes the import actually referenced. Now that ValidationUtils.validateOptionSet reads from TrackerPreheat's targeted (option set, code) cache populated by OptionValueSupplier instead of OptionSet.getOptions(), this mapping is dead weight and safe to remove. Verified via: - dhis-service-tracker suite (135 tests, mock-based/unit) green - dhis-test-integration real end-to-end tests (54 tests: Event/ TrackedEntity/EnrollmentAttr ImportValidationTest) green against a real DB and the real preheat pipeline with the mapping removed - Two mutation tests confirming coverage is non-vacuous: disabling OptionValueSupplier.addCandidates and switching validateOptionSet's allMatch to anyMatch both broke the expected tests for the expected reason * fix: resolve option codes introduced by program rule ASSIGN actions The tracker import preheat only resolves the (option set, code) pairs the import payload actually references, so validation can check option codes without materializing whole OptionSet.options collections. Program rule ASSIGN actions break that assumption: they can add or overwrite data values and attributes that were not in the payload, so the codes they introduce were never resolved and valid data was rejected with E1125. Fix it where the assigned values first become known: the values an ASSIGN action will apply are the rule engine's already evaluated output, captured in the Assign*Executor objects that calculateRuleEffects builds from RuleEffects#getData. Nothing evaluated later can change them, so right after calculateRuleEffects returns we can collect every (target, value) pair from those executors and resolve it through OptionValueSupplier's existing entry point, all within the same "Running Rule Engine" stage. No validator chain changes are needed, and the whole bundle costs one batched query, or none at all when no ASSIGN action is present. Note the injection point matters. Doing this over the bundle's entities instead of its executors would be a no-op: calculateRuleEffects only computes RuleEffects, it mutates nothing. The mutations happen later, per entity, inside the rule engine validators, interleaved with the very validators that read the resolved codes, so there is no whole-bundle boundary in between where the mutated entities could be re-read. Identifiers are converted with TrackerPreheat#getIdSchemes, the same way the executors convert them when applying a value, because preheat metadata is keyed by the payload's id scheme, not by UID. Also adds a diagnostic warning distinguishing "this code was checked and does not exist" from "this option set was never resolved", the latter being an internal bug rather than user error. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: suppress SonarCloud false positive on two-phase loop in OptionValueSupplier The two loops in queryChunk cannot be combined: the second depends on confirmed, which is only populated by the JDBC query that runs between them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * perf: stop tracker preheat from fully materializing OptionSet.options Preheat only the (option set, code) pairs actually referenced by an import payload, via a new OptionValueSupplier that queries optionvalue directly (unnest parallel-array join), instead of relying on OptionSetMapper to hydrate entire OptionSet.options collections (including per-row JSONB attributevalues deserialization) regardless of how many option codes the import actually references. ValidationUtils.validateOptionSet reads from TrackerPreheat's targeted (option set, code) cache instead of OptionSet.getOptions(). Program rule ASSIGN actions are resolved through the same supplier right after calculateRuleEffects, since they can introduce option-set values that were never in the original payload. Verified functionally equivalent to master's #24850, file by file, with 2.41's own type conventions kept where master has since diverged (String ids predating UID, javax.persistence.EntityManager, etc.). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>




Same PR as #24815
Awaiting Team tracker approval.