fix: avoid loading UserRole members during JSON Patch apply [DHIS2-21852]#24489
fix: avoid loading UserRole members during JSON Patch apply [DHIS2-21852]#24489netroms wants to merge 12 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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?
|
Follow-up on system-wide PATCH side effectsThanks — valid concern given this sits in Extra coverage added
Skip rules (unchanged for collections; non-persisted props)
PUT |
Spec for multi-entity integration + web-api coverage answering David's system-wide PATCH concern on PR #24489 / DHIS2-21852.
Task breakdown for multi-entity integration + web-api coverage on PR #24489 / DHIS2-21852.
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.
b55c194 to
f12cfba
Compare
…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>
…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>
…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>
|



Summary
PATCH /api/userRoles/{uid}(JSON Patch) hydrated the entire lazyUserRole.memberscollection 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 oneUPDATE userrole.Root cause
JsonPatchManager.applyserializes the live Hibernate entity twice:valueToTreeinvokes every@JsonPropertygetter (includingUserRole.getUsers(), which iterates the lazymembersset), andhandleCollectionUpdatesthen re-invokes every schema collection getter. The serializedusersdata is thrown away:UserRolehas nosetUsers, 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:
valueToTreeuses a per-call mapper copy with a@JsonFiltermixin bound to the entity class only (excluded getters are never invoked; nested objects serialize unchanged; fast path when nothing is excluded).handleCollectionUpdatesskips excluded properties beforesafeInvoke.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.UserGroupmembers) without per-entity special cases.Performance
A/B on the platform-perf DB (~250k users), both images built from the same base commit,
PATCHreplace /description, Gatling sequential:Patched latency is independent of membership size (~600x at 83k members). SQL statement log confirms the
userrolemembershydrate query is gone and membership rows are unchanged after PATCH. GET is unaffected.Testing
JsonPatchManagerTest: new invariant test assertingHibernate.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.UserRolesPerformanceTestGatling simulation (used for the numbers above; no calibrated thresholds yet, asserts 100% success).JIRA: DHIS2-21852
AI Assisted