perf: add direct org unit predicate for orgUnitMode=SELECTED event queries [2.41]#23492
Draft
jason-p-pickering wants to merge 4 commits into2.41from
Draft
perf: add direct org unit predicate for orgUnitMode=SELECTED event queries [2.41]#23492jason-p-pickering wants to merge 4 commits into2.41from
jason-p-pickering wants to merge 4 commits into2.41from
Conversation
|
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.



Background
For single-event (
WITHOUT_REGISTRATION) programs, theGET /tracker/eventsendpoint withorgUnitMode=SELECTEDwas producing slow queries on a production-scale system with ~12.4 millionevents in a program. Two bugs combined to cause this: the org unit predicate was never emitted
for this program type, and the
enrollmentidindex on the shared event table was systematicallymisleading the query planner into a catastrophic scan path.
Root cause — part 1: org unit predicate never emitted
The 2.41 query uses
COALESCE(po.organisationunitid, ev.organisationunitid)to handle trackerprogram ownership semantics — the effective org unit for a tracker event may differ from the
stored
ev.organisationunitidif ownership has been transferred. This expression prevents theplanner from using the org unit equality as an early filter: it cannot see through a COALESCE to
use an index on the underlying column.
For
WITHOUT_REGISTRATIONprograms there are no tracked entities and therefore no TPO rows —the COALESCE always resolves to
ev.organisationunitid. The fix emits the predicate directly onev.organisationunitidat query build time for this program type, bypassing the COALESCEentirely. Without this predicate the planner has no selective entry point and falls back to
scanning all events for the program stage (~918K rows, ~6,200ms).
Root cause — part 2:
enrollmentidindex poisons the planner for single-event programsFor
WITHOUT_REGISTRATIONprograms, DHIS2 creates a single synthetic enrollment per program.Every event in that program shares the same
enrollmentid. This meansprogramstageinstance_programinstanceid— the index onevent.enrollmentid— can never beselective for these programs: a scan by
enrollmentidalways returns the entire program's eventset. On this production system that is 12.3M rows from a single index entry.
The query planner cannot know this from column statistics alone. It estimates selectivity from the
average rows per distinct
enrollmentidvalue. With a handful of distinct values and oneoutlier owning 97% of the table, the average is ~4,200 — causing the planner to consistently
underestimate the true cost by a factor of ~3,000 and prefer a BitmapAnd using the enrollmentid
index regardless of what other, genuinely selective indexes are available.
The shared event table (pre-2.43) therefore has a structural tension: the
enrollmentidindex isdesigned for tracker semantics (one enrollment per patient, few events each) but is also visible
to single-event program queries where it is always the wrong choice — actively preventing the
planner from choosing better paths. In 2.43 the tracker and event stores are separated, which
resolves this at the schema level. For 2.41 and 2.42 — which will remain in production at many
sites for years — the tension persists.
Historical context
The default DHIS2 client sends no
occurredAfter/occurredBeforeparameters. This means thetrue upstream baseline is an unbounded query — no date range, no OU predicate — and every
production instance running stock DHIS2 hits this path when loading the working list in the
Capture app.
The progression below uses real
EXPLAIN ANALYZEoutput from the same production database:¹ Site-specific optimisation described below — not part of this branch.
Baseline → date range: reducing the event window cut SubPlan iterations from 10.6M to 3,233.
However, the dominant cost shifted rather than disappeared: PostgreSQL still had to build a bitmap
over all 10.6M program events via
programstageinstance_programinstanceidbefore intersectingwith the date bitmap. That bitmap construction alone consumed ~2,386ms of the 2,613ms total. The
date range was treating the symptom — reducing the bitmap size — while the enrollmentid index
remained the structural cause.
Date range → this branch: the
ev.organisationunitid = :ou_idpredicate gives the planner adirect index entry via
idx_event_ou_occurreddate(organisationunitid, occurreddate). With anarrow date range, the planner uses an
Index Scan Backward— no bitmap construction, no massSubPlan iterations. The two predicates compound: the date range narrows the index range scan; the
OU equality limits the result to events at the requested facility.
On the date injection: with the partial enrollmentid index applied (see below), an unbounded
query with no date range runs in ~9ms. The date injection at the reverse proxy was never a fix —
it was a workaround for the enrollmentid index problem that remained hidden until this analysis.
A bounded date range remains good UX practice — users generally have no interest in events from
5+ years ago in a working list — but it is no longer required to prevent database overload on
deployments that have addressed the enrollmentid index.
Changes
Bug fix — program type condition always false
The
WITHOUT_REGISTRATIONbranch condition usedparams.getProgramType(), which is neverpopulated on
EventQueryParamsin the tracker store — always returning null. The condition wastherefore always false, meaning the COALESCE path was used for all programs regardless of type.
Fixed to use
params.getEnrolledInProgram().getProgramType(), consistent with howisProgramRestricted()works in the same class.This was invisible to CI because all existing
orgUnitMode=SELECTEDintegration tests inEventExporterTestused aWITH_REGISTRATIONprogram (BFcipDERJnf).Integration test added
shouldReturnEventsForWithoutRegistrationProgramGivenOrgUnitModeSelectedinEventExporterTest— exercises
orgUnitMode=SELECTEDwith aWITHOUT_REGISTRATIONprogram (iS7eutanDry), closingthe test gap that hid both bugs.
Flyway migration V2_41_58 — composite index
Even with the correct
ev.organisationunitid = :ou_idpredicate in place, the enrollmentidindex can still lure the planner into a BitmapAnd for high-volume org units with wide date ranges.
The composite index
(organisationunitid, occurreddate)gives the planner a competing entry pointthat wins when the (OU, date range) combination is selective — typically up to a few weeks of
events per facility. The planner uses
Index Scan Backwardcovering both predicates in a singlepass and can stop as soon as the
LIMITis satisfied without sorting the full result set.For very wide date ranges (months to years) on high-volume org units, the planner may still choose
the BitmapAnd path (~900ms). In practice the intended use case — Capture app working list with a
bounded date window — stays well inside the fast path. For deployments where this ceiling is still
a problem, see the site-specific optimisation below.
Site-specific optimisation for pre-2.43 event-heavy deployments
On deployments that have been collecting single-event program data for many years, the enrollment
distribution can become pathologically skewed. The production system used for this analysis:
In this shape the
eventtable is functionally a flat aggregate table(organisationunitid, occurreddate, datavalues)—enrollmentidis a data model artefact withno filtering value. DB admins can eliminate the BitmapAnd path entirely by replacing the full
enrollmentidindex with a partial index that excludesWITHOUT_REGISTRATIONprogram stages:With this in place, even 1-year date range queries on high-volume org units use
Index Scan Backwardviaidx_event_ou_occurreddateand execute in under 10ms. This is notincluded in the Flyway migration because it requires knowledge of site-specific programstageids
and is inappropriate for tracker-heavy deployments, but it is a straightforward DBA operation and
the correct long-term fix for deployments where single-event programs dominate the event table.
Performance results (production, ~12.7M row event table)
Baseline is stock 2.41 with no date bounds and no OU predicate — the query shape every production
instance hits when the Capture app loads a working list without custom date parameters.
¹ Site-specific DBA optimisation — not part of this branch.
² Date range injection via reverse proxy — a workaround, not a fix.
For the common case — facility-level queries with a bounded date range — the 12M-row
programstageinstance_programinstanceidscan is eliminated from the plan. For very wide dateranges on high-volume org units, the planner may still select the BitmapAnd path; this is a known
ceiling addressable with the partial index approach described above.
Scope
These changes apply to the event query for
orgUnitMode=SELECTEDonWITHOUT_REGISTRATIONprograms only. This query pattern is issued by the Capture app when loading the initial working
list — it is high volume, fired on every page load for every user, and therefore latency-sensitive.
The
WITH_REGISTRATION(tracker) case uses the COALESCE join which cannot be bypassed in thesame way — that ceiling requires a separate architectural change and is out of scope here.
Disclaimer: 🤖 AI was used for portions of this PR.