Skip to content

fix: skip getter invocation/recursion for PropertyTransformer fields in field filtering [DHIS2-21867]#24514

Merged
jason-p-pickering merged 4 commits into
masterfrom
fix/DHIS2-21867-user-property-transformer-n1
Jul 22, 2026
Merged

fix: skip getter invocation/recursion for PropertyTransformer fields in field filtering [DHIS2-21867]#24514
jason-p-pickering merged 4 commits into
masterfrom
fix/DHIS2-21867-user-property-transformer-n1

Conversation

@jason-p-pickering

@jason-p-pickering jason-p-pickering commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • FieldPathHelper.visitFieldPath (used by FieldFilterService.applyAccess/applySharingDisplayNames) had no short-circuit for properties annotated with @PropertyTransformer (e.g. UserPropertyTransformer, used on OrganisationUnit.users, UserRole.users, UserGroup.members, createdBy/lastUpdatedBy/user, and ~10 other sites).
  • Those properties always serialize a fixed 5-field DTO regardless of what's requested underneath, so walking into every sub-path to compute Access/sharing-display-name mutations was pure waste — and on a Hibernate-backed collection it forced full lazy-association materialization (N+1) just to throw the result away.
  • Fix: short-circuit in visitFieldPath via property.hasPropertyTransformer(), which is already tracked on the schema. Closes this for every @PropertyTransformer site with one change.
  • Related: found while reviewing fix: avoid full User materialization for userRole/userGroup members projection [DHIS2-21860] #24512 (DHIS2-21860), which fixed the same class of N+1 for UserRole/UserGroup members specifically, but only as a side effect of substituting a transient User — it didn't fix the general visitFieldPath behavior, so sites like OrganisationUnit.users remained exposed.

Jira: https://dhis2.atlassian.net/browse/DHIS2-21867

Measured impact

Glowroot trace, GET /api/organisationUnits/{id}?fields=id,name,users[id,name,userRoles[id,name]] on an org unit with many users, before vs. after this fix (same data, same server, only this change applied):

Before After
Duration 69.65s 18.6s (3.7x)
JDBC queries 250,054 3 (~83,000x fewer)
Main-thread CPU 51.7s 17.2s
Bytes allocated 30.3GB 14.7GB

The query-count collapse confirms the root cause: visitFieldPath was calling user.getUserRoles() once per user while walking the users.userRoles.id/users.userRoles.name leaves looking for access/sharing segments that were never there, triggering one lazy-load per user. Post-fix, 0 CPU profile samples land in FieldPathHelper at all (was 8/684 pre-fix, and the query storm it triggered dominated the rest).

Not in scope for this PR: the remaining 18.6s is now dominated by (a) the one legitimate getUsers() call Jackson still needs to serialize the users array — OrganisationUnit.users never got the applyUserSummaries-style raw-SQL projection that #24512 gave UserRole/UserGroup, so that collection still fully materializes via Hibernate — and (b) Ehcache 2nd-level-cache putFromLoad paying a full Java-serialization round-trip per cached User entity (ObjectInputStream/SerializingCopier, ~73% of post-fix CPU samples). Both predate this change (visible as a smaller share of the much larger pre-fix profile) and are being tracked as a separate follow-up.

CI comparison (Gatling, perf runner)

Independent confirmation on real, isolated CI hardware (not a locally-instrumented trace): performance-tests-compare.yml run, dhis2/core-dev:latest (master) vs. dhis2/core-pr:24514 (this branch), platform-perf DB, OrganisationUnitUsersFieldFilterPerformanceTest, 10 iterations each (excluding warmup):

Baseline (master) Candidate (this branch)
min 50,773ms 21,597ms
mean 51,574ms 30,999ms
p50 51,600ms 39,053ms
p95 / max 52,212ms 42,634ms

Master is a flat ~51–52s on every one of the 10 requests (the N+1 storm is deterministic). The branch ranges 21.6–42.6s, consistent with the remaining, separately-tracked bottleneck above being sensitive to 2nd-level-cache warmth between requests.

Test plan

  • New unit test FieldPathHelperVisitFieldPathTest reproduces the bug (getter invoked despite a PropertyTransformer on the property) against unfixed code, and passes after the fix.
  • dhis-service-field-filtering module test suite passes.
  • OrganisationUnitControllerTest, UserControllerTest, FieldFilterServiceTest, SharingControllerTest (dhis-test-web-api) and DefaultFieldFilterServiceTest (dhis-service-dxf2) pass unchanged — output JSON is unaffected since the transformer already discarded that subtree; only the wasted lazy-load work is removed.
  • Verified against a live Glowroot-instrumented server (see Measured impact above).
  • New Gatling regression guard OrganisationUnitUsersFieldFilterPerformanceTest (dhis-test-performance) added, and used to produce the CI comparison above.

🤖 Generated with Claude Code

…in field filtering [DHIS2-21867]

FieldPathHelper.visitFieldPath (used by FieldFilterService.applyAccess and
applySharingDisplayNames) walked every requested sub-path under a property
regardless of whether that property has a PropertyTransformer, invoking the
real getter and recursing into every collection element. For properties like
OrganisationUnit.users, UserRole.users, UserGroup.members, etc. (all
annotated with @PropertyTransformer(UserPropertyTransformer.class)), the
final JSON shape is always a fixed 5-field DTO independent of what's
requested underneath, so that walk computes Access/sharing-display-name
mutations for leaves that can never appear in the response -- on a
Hibernate-backed collection this forces full lazy-association
materialization (N+1) purely to throw the result away.

property.hasPropertyTransformer() is already tracked on the schema; adding
a short-circuit in visitFieldPath closes this for every PropertyTransformer
site with one change instead of per-entity workarounds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@jason-p-pickering
jason-p-pickering requested review from a team and david-mackessy July 21, 2026 08:42
…es N+1 [DHIS2-21867]

OrganisationUnitUsersFieldFilterPerformanceTest hits GET
/api/organisationUnits/{uid}?fields=id,name,users[id,name,userRoles[id,name]]
against the platform-perf DB's root org unit -- the exact repro from
DHIS2-21867, which forced 250,054 JDBC queries / ~70s before this branch's
fix to FieldPathHelper.visitFieldPath. Targets the same platform-perf DB
UIDs already used by UsersPerformanceTest.

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

@jbee jbee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about it. While the visiting of the object tree in general is generic we only use it to supply display name and access fields in some cases. This would never apply to a property that is transformed via the property transformer which this PR uses to short-circuit the visit.

In think we could even exclude paths from the visit that have a transformer attached from fields as the same logic applies that what is rendered is transformed and would not need injecting some transient data.

…hold from real CI run

30s was too tight: a real baseline-vs-candidate CI run (perf runner,
platform-perf DB, 10 iterations) showed the fixed branch ranging
21,597-42,634ms (p95 42,634ms) depending on 2nd-level-cache warmth, against
master's flat ~51-52s. Raise to 48s -- enough headroom above the fixed
range's observed max while staying below master's observed floor.

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

Protocol-level basicAuth() re-verifies the bcrypt password on every
request, polluting the measured GET's response time with a fixed ~70ms
auth cost repeated per iteration. Switch to the UsersPerformanceTest
pattern: authenticate once per virtual user via a separately-named
request, then reuse the session cookie so the measured endpoint reflects
only its own cost.

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

Copy link
Copy Markdown

@jason-p-pickering
jason-p-pickering merged commit 2c241e8 into master Jul 22, 2026
25 checks passed
@jason-p-pickering
jason-p-pickering deleted the fix/DHIS2-21867-user-property-transformer-n1 branch July 22, 2026 11:02
jason-p-pickering added a commit that referenced this pull request Jul 22, 2026
… time [DHIS2-21856] (#24519)

* fix: prune PropertyTransformer-controlled subtrees at field-expansion time [DHIS2-21856]

A property annotated @PropertyTransformer (e.g. UserPropertyTransformer, used
on OrganisationUnit.users, UserRole.users, UserGroup.members, and ~12 other
sites) always serializes a fixed shape regardless of what's requested
underneath it -- verified empirically against a live server: fields=users,
fields=users[href] and fields=users[*] all produce byte-identical responses,
one in 6.4s and the other in 138.5s / 583,334 JDBC queries.

Three independent subsystems were each rediscovering (or failing to
discover) this fact on their own:
- FieldFilterService.applyAccess/applySharingDisplayNames, fixed narrowly by
  #24514 (DHIS2-21867) via a check inside FieldPathHelper.visitFieldPath.
- DefaultLinkService.generateLink, via AbstractFullReadOnlyController's
  hasHref/fieldsContains -- a raw substring check on the unparsed fields
  string (fields.contains("*")) that can't tell a top-level wildcard from
  one nested four levels deep inside users[*], causing LinkService to
  reflectively invoke every IdentifiableObject getter on the schema
  (including UserRole.getUsers()) before field-filtering ever runs.
- Jackson's actual serialization, which legitimately needs to enumerate the
  collection once -- not addressed here.

Root cause, generalized: FieldPathHelper.apply() generates descendant field
paths under a transformer-controlled property in the first place (confirmed
via FieldFilterParser.parse("users[*]") -> "users", "users.:all", which then
expands into one leaf per property on the target schema, recursively). Every
consumer of the expanded list has to independently re-derive "am I under a
PropertyTransformer" to defensively skip it.

Fix:
- FieldPathHelper.apply() now prunes any path beneath a
  PropertyTransformer-annotated property as a final step, before any
  consumer sees the list. The property's own bare path always survives
  (Jackson only needs it present to include the field at all; its
  transformer's DTO has no @jsonfilter, so descendant paths were already a
  no-op for serialization -- confirmed by tracing UserPropertyTransformer's
  UserDto).
- AbstractFullReadOnlyController.hasHref now asks the real, now-pruned
  field-path expansion instead of substring-matching -- correct for every
  phrasing (href, id,href, *, :all, :simple, users[href], users[*]) with no
  special-casing, and fieldsContains is deleted.

This makes #24514's visitFieldPath check redundant in practice (the paths
it guards against no longer exist to visit) but harmless to keep as
defense-in-depth. Supersedes #24512 (DHIS2-21860), which the team judged too
specific/hard to maintain (per-entity raw-SQL projection plus a
beforeLinkGeneration hook) -- this fixes the shared root for every
@PropertyTransformer site with one change instead.

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

* test: update skipSharingFieldsExcludeCorrectFieldsTest for PropertyTransformer pruning [DHIS2-21856]

createdBy/lastUpdatedBy use UserPropertyTransformer, so FieldPathHelper.apply()'s
new subtree pruning correctly drops their now-dead createdBy.id/lastUpdatedBy.id
descendant paths generated by the :owner preset expansion, while keeping the
properties' own bare paths. Expected count drops from 58 to 56 accordingly;
added explicit assertions on the surviving/pruned paths so this doesn't regress
to a bare count check again.

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

* test: collapse PropertyTransformer pruning tests into one parameterized test [DHIS2-21856]

Addresses SonarCloud MAJOR finding on the PR (three near-identical @test
methods differing only in the input fields expression). Mutation-tested:
reverting the pruning logic makes 2 of the 3 parameterized cases fail for
the right reason (the bare "members" case is unaffected either way, since
it has no descendants to prune).

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants