Skip to content

fix: avoid loading UserRole members during JSON Patch apply [DHIS2-21852]#24489

Open
netroms wants to merge 12 commits into
masterfrom
fix-userrole-jsonpatch-lazy-members
Open

fix: avoid loading UserRole members during JSON Patch apply [DHIS2-21852]#24489
netroms wants to merge 12 commits into
masterfrom
fix-userrole-jsonpatch-lazy-members

Conversation

@netroms

@netroms netroms commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

PATCH /api/userRoles/{uid} (JSON Patch) hydrated the entire lazy UserRole.members collection even for scalar-only patches, making a one-column update cost O(members) time and allocation. Production trace: ~4s wall / ~707 MB alloc for a role with ~320 members, where the useful work was one UPDATE userrole.

Root cause

JsonPatchManager.apply serializes the live Hibernate entity twice: valueToTree invokes every @JsonProperty getter (including UserRole.getUsers(), which iterates the lazy members set), and handleCollectionUpdates then re-invokes every schema collection getter. The serialized users data is thrown away: UserRole has no setUsers, and the metadata importer ignores non-owner properties on UPDATE.

Fix

Skip collection properties that are non-owner and not referenced by any patch path, in both serialization passes:

  • valueToTree uses a per-call mapper copy with a @JsonFilter mixin bound to the entity class only (excluded getters are never invoked; nested objects serialize unchanged; fast path when nothing is excluded).
  • handleCollectionUpdates skips excluded properties before safeInvoke.

Owner collections (e.g. authorities) always stay serialized, since omitting them would clear them on import UPDATE. Non-owner collections referenced by patch paths behave exactly as before. This also covers other fat inverse collections (e.g. UserGroup members) without per-entity special cases.

Performance

A/B on the platform-perf DB (~250k users), both images built from the same base commit, PATCH replace /description, Gatling sequential:

Role size baseline p50 baseline max patched p50 patched p95
0 members 28 ms 33 ms 19 ms 22 ms
2,000 members 263 ms 295 ms 21 ms 25 ms
83,334 members 10,833 ms 14,151 ms 18 ms 21 ms

Patched latency is independent of membership size (~600x at 83k members). SQL statement log confirms the userrolemembers hydrate query is gone and membership rows are unchanged after PATCH. GET is unaffected.

Testing

  • JsonPatchManagerTest: new invariant test asserting Hibernate.isInitialized(role.getMembers()) stays false after a scalar patch apply; owner-collection (authorities) preservation; /users-path parity. 13/13 green.
  • UserControllerTest: PATCH description on a role with users returns 200, description updated, users retained on GET.
  • New UserRolesPerformanceTest Gatling simulation (used for the numbers above; no calibrated thresholds yet, asserts 100% success).

JIRA: DHIS2-21852

AI Assisted

netroms added 2 commits July 18, 2026 02:50
JsonPatchManager converted the managed entity to a tree via valueToTree
and then re-invoked every collection getter in handleCollectionUpdates.
For UserRole, getUsers() initializes all lazy members, causing
O(members) SQL and hundreds of MB of allocation on scalar PATCHes.

Skip non-owner collection properties that no patch path references, in
both serialization passes. Non-owner collections are ignored by
metadata import UPDATE, so omitting them is semantically free; owner
collections always stay in the tree (omitting them would clear them on
import). Non-owner collections referenced by patch paths keep today's
behavior.
Gatling simulation patching a scalar field on an empty control role and
on a large-membership role (platform-perf DB), making the O(members)
PATCH regression visible. No calibrated thresholds yet; asserts 100%
success only.

@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 don't like how the mechanics of the filtering work. We have a constant hard coded into a jackson mapping filter as well as into a @JsonFilter annotation on a mixin thing. Way to complicated and entangled to be flexible or extendable for other cases. I think we should keep this simple and just hard code this as explicit and hard-coded as we can make it ideally just being in one place.

As I understand this approach it also means you cannot patch the excluded property even if targeted explicitly. If that is the case maybe we should throw an exception if the user request does attempt such a patch?

@david-mackessy

Copy link
Copy Markdown
Contributor
  • great to have a perf test cover this 👍
  • i'd be a little worried about unknown side effects of this given it applies system-wide for all PATCHes
  • the outcome seems good & this could have benefits in other unknown n+1s too
  • does PUT in UserRoleController have the same issue?

@netroms
netroms marked this pull request as draft July 21, 2026 07:52
@netroms

netroms commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on system-wide PATCH side effects

Thanks — valid concern given this sits in JsonPatchManager used by all metadata PATCH.

Extra coverage added

  • Integration (JsonPatchManagerTest): scalar apply must not initialize inverse collections on OrganisationUnit (children/users), User (userGroups), and UserGroup.managedByGroups; owner collections still present (User.userRoles, UserGroup.users, DataElementGroup.members, existing UserRole.authorities).
  • Web-api (JsonPatchSideEffectControllerTest): HTTP PATCH name/firstName on OU / UserGroup / User leaves children/users/userGroups unchanged; PATCH replace /users on UserRole does not drop membership.

Skip rules (unchanged for collections; non-persisted props)

  • Collections: only isCollection && !isOwner && path not referenced is omitted. Owner collections always serialize (omitting them would clear on import UPDATE). Note UserGroup.users is owner, so it is not skipped; the new test asserts members still round-trip on name PATCH.
  • Non-persisted properties (e.g. OrganisationUnit.leaf) are also omitted when unreferenced, because derived getters can force-init inverse collections and defeat the lazy-member skip.

PUT /userRoles/{uid} — different code path
PUT does not go through JsonPatchManager. AbstractCrudController.putJsonObject deserializes the request body and calls importMetadata(UPDATE) only. The lazy members hydrate bug was PATCH-only (doPatchjsonPatchManager.applyvalueToTree / handleCollectionUpdates). UserRoleController does not override PUT/PATCH.

netroms added a commit that referenced this pull request Jul 23, 2026
Spec for multi-entity integration + web-api coverage answering
David's system-wide PATCH concern on PR #24489 / DHIS2-21852.
netroms added a commit that referenced this pull request Jul 23, 2026
Task breakdown for multi-entity integration + web-api coverage
on PR #24489 / DHIS2-21852.
netroms added 6 commits July 23, 2026 22:21
OrganisationUnit.leaf calls children.isEmpty(), which initializes the
inverse children collection even when JsonPatchManager already omits
non-owner collections. Exclude unreferenced non-persisted properties
from patch serialization so derived getters cannot force lazy loads.
@netroms
netroms force-pushed the fix-userrole-jsonpatch-lazy-members branch from b55c194 to f12cfba Compare July 23, 2026 14:24
jason-p-pickering added a commit that referenced this pull request Jul 23, 2026
…FilterService pattern [DHIS2-21852]

Addresses review feedback on #24489 (Jan Bernitt): the per-call `.addMixIn(realClass, ...)` +
inline mixin + filter-provider wiring in JsonPatchManager.apply() was rebuilt from scratch on every
call and didn't follow any existing convention. Reworks it to mirror the codebase's established
pattern for the same underlying problem -- skipping a getter during Jackson serialization based on
per-call criteria, without invoking it -- already used for the `?fields=` GET path
(org.hisp.dhis.fieldfiltering.FieldFilterService/FieldFilterMixin).

- New `JsonPatchFilterMixin` (`@JsonFilter`) and `JsonPatchExcludedPropertyFilter`
  (`SimpleBeanPropertyFilter`), shaped like `FieldFilterMixin`/`FieldFilterSimpleBeanPropertyFilter`.
- `JsonPatchManager` caches one mixin-bound `ObjectMapper` copy per entity type
  (`ConcurrentHashMap<Class<?>, ObjectMapper>`, built lazily via `computeIfAbsent`) instead of
  rebuilding the mixin binding on every `apply()` call. `findExcludableNonOwnerCollections` (the
  exclusion rule itself) is unchanged -- it was already schema-driven and generic.
- The mixin is scoped per-`realClass`, not bound to `Object.class` globally: `FieldFilterMixin`'s own
  filter is path-aware, so a global binding is safe for it, but `JsonPatchExcludedPropertyFilter`
  matches by property name only. A global binding was tried first and found, in review, to silently
  strip `Sharing.users`/`Sharing.userGroups` (present on every `IdentifiableObject`) whenever a
  same-named top-level collection was excluded, e.g. on a scalar UserRole PATCH. Scoping per-class
  makes that cross-type collision impossible. Regression test:
  `testUserRoleSharingUsersSurviveScalarPatch`.
- Answers Jan's second question (should an explicit patch to an excluded property throw?): no --
  `findExcludableNonOwnerCollections` already un-excludes a property referenced by a patch path, so
  it falls back to full (slower, correct) hydration rather than being silently dropped. Replaced the
  previous `testUserRoleUsersPathPatchDoesNotThrow` (asserted only `assertNotNull`, would pass either
  way) with `testUserRoleUsersPathPatchFallsBackToFullHydration`, which asserts
  `Hibernate.isInitialized(...)` actually flips to `true`, mutation-tested against the fallback logic.

No functional change to PATCH behavior beyond fixing the Object.class regression introduced and
caught within this same review cycle (never released). All existing JsonPatchManagerTest coverage
(14 tests) plus the new JsonPatchExcludedPropertyFilterTest (2 tests) pass; UserControllerTest (52
tests, dhis-test-web-api) unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
jason-p-pickering added a commit that referenced this pull request Jul 23, 2026
…FilterService pattern [DHIS2-21852]

Addresses review feedback on #24489 (Jan Bernitt): the per-call `.addMixIn(realClass, ...)` +
inline mixin + filter-provider wiring in JsonPatchManager.apply() was rebuilt from scratch on every
call and didn't follow any existing convention. Reworks it to mirror the codebase's established
pattern for the same underlying problem -- skipping a getter during Jackson serialization based on
per-call criteria, without invoking it -- already used for the `?fields=` GET path
(org.hisp.dhis.fieldfiltering.FieldFilterService/FieldFilterMixin).

- New `JsonPatchFilterMixin` (`@JsonFilter`) and `JsonPatchExcludedPropertyFilter`
  (`SimpleBeanPropertyFilter`), shaped like `FieldFilterMixin`/`FieldFilterSimpleBeanPropertyFilter`.
- `JsonPatchManager` caches one mixin-bound `ObjectMapper` copy per entity type
  (`ConcurrentHashMap<Class<?>, ObjectMapper>`, built lazily via `computeIfAbsent`) instead of
  rebuilding the mixin binding on every `apply()` call. `findExcludableNonOwnerCollections` (the
  exclusion rule itself) is unchanged -- it was already schema-driven and generic.
- The mixin is scoped per-`realClass`, not bound to `Object.class` globally: `FieldFilterMixin`'s own
  filter is path-aware, so a global binding is safe for it, but `JsonPatchExcludedPropertyFilter`
  matches by property name only. A global binding was tried first and found, in review, to silently
  strip `Sharing.users`/`Sharing.userGroups` (present on every `IdentifiableObject`) whenever a
  same-named top-level collection was excluded, e.g. on a scalar UserRole PATCH. Scoping per-class
  makes that cross-type collision impossible. Regression test:
  `testUserRoleSharingUsersSurviveScalarPatch`.
- Answers Jan's second question (should an explicit patch to an excluded property throw?): no --
  `findExcludableNonOwnerCollections` already un-excludes a property referenced by a patch path, so
  it falls back to full (slower, correct) hydration rather than being silently dropped. Replaced the
  previous `testUserRoleUsersPathPatchDoesNotThrow` (asserted only `assertNotNull`, would pass either
  way) with `testUserRoleUsersPathPatchFallsBackToFullHydration`, which asserts
  `Hibernate.isInitialized(...)` actually flips to `true`, mutation-tested against the fallback logic.

No functional change to PATCH behavior beyond fixing the Object.class regression introduced and
caught within this same review cycle (never released). All existing JsonPatchManagerTest coverage
(14 tests) plus the new JsonPatchExcludedPropertyFilterTest (2 tests) pass; UserControllerTest (52
tests, dhis-test-web-api) unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
jason-p-pickering and others added 3 commits July 23, 2026 17:25
…FilterService pattern [DHIS2-21852]

Addresses review feedback on #24489 (Jan Bernitt): the per-call `.addMixIn(realClass, ...)` +
inline mixin + filter-provider wiring in JsonPatchManager.apply() was rebuilt from scratch on every
call and didn't follow any existing convention. Reworks it to mirror the codebase's established
pattern for the same underlying problem -- skipping a getter during Jackson serialization based on
per-call criteria, without invoking it -- already used for the `?fields=` GET path
(org.hisp.dhis.fieldfiltering.FieldFilterService/FieldFilterMixin).

- New `JsonPatchFilterMixin` (`@JsonFilter`) and `JsonPatchExcludedPropertyFilter`
  (`SimpleBeanPropertyFilter`), shaped like `FieldFilterMixin`/`FieldFilterSimpleBeanPropertyFilter`.
- `JsonPatchManager` caches one mixin-bound `ObjectMapper` copy per entity type
  (`ConcurrentHashMap<Class<?>, ObjectMapper>`, built lazily via `computeIfAbsent`) instead of
  rebuilding the mixin binding on every `apply()` call. `findExcludableNonOwnerCollections` (the
  exclusion rule itself) is unchanged -- it was already schema-driven and generic.
- The mixin is scoped per-`realClass`, not bound to `Object.class` globally: `FieldFilterMixin`'s own
  filter is path-aware, so a global binding is safe for it, but `JsonPatchExcludedPropertyFilter`
  matches by property name only. A global binding was tried first and found, in review, to silently
  strip `Sharing.users`/`Sharing.userGroups` (present on every `IdentifiableObject`) whenever a
  same-named top-level collection was excluded, e.g. on a scalar UserRole PATCH. Scoping per-class
  makes that cross-type collision impossible. Regression test:
  `testUserRoleSharingUsersSurviveScalarPatch`.
- Answers Jan's second question (should an explicit patch to an excluded property throw?): no --
  `findExcludableNonOwnerCollections` already un-excludes a property referenced by a patch path, so
  it falls back to full (slower, correct) hydration rather than being silently dropped. Replaced the
  previous `testUserRoleUsersPathPatchDoesNotThrow` (asserted only `assertNotNull`, would pass either
  way) with `testUserRoleUsersPathPatchFallsBackToFullHydration`, which asserts
  `Hibernate.isInitialized(...)` actually flips to `true`, mutation-tested against the fallback logic.

No functional change to PATCH behavior beyond fixing the Object.class regression introduced and
caught within this same review cycle (never released). All existing JsonPatchManagerTest coverage
(14 tests) plus the new JsonPatchExcludedPropertyFilterTest (2 tests) pass; UserControllerTest (52
tests, dhis-test-web-api) unaffected.

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

Copy link
Copy Markdown

@netroms
netroms marked this pull request as ready for review July 24, 2026 10:53
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