Skip to content

perf: Optimize user creation and deletion#22936

Open
jason-p-pickering wants to merge 39 commits intomasterfrom
fix-usergroup-member-loading
Open

perf: Optimize user creation and deletion#22936
jason-p-pickering wants to merge 39 commits intomasterfrom
fix-usergroup-member-loading

Conversation

@jason-p-pickering
Copy link
Contributor

@jason-p-pickering jason-p-pickering commented Feb 10, 2026

Summary

Fixes N+1 query performance problems when creating/updating users in systems with large numbers of users and user groups originally reported in https://dhis2.atlassian.net/browse/DHIS2-20614

Problem

Glowroot profiling on a production-scale database identified two major performance bottlenecks when creating a single user via the User app:

  1. SchemaToDataFetcher loading ALL users.: During metadata import, uniqueness checking loaded all 221,647 users from the database (SELECT code, ldapid, username FROM userinfo - 75.6ms, 221K rows), even when importing just one user.

  2. UserGroup member collection loading : Adding/removing users from groups called userGroup.addUser(user) / userGroup.removeUser(user), which triggered lazy initialization of the entire members collection. Similarly, updateUserGroups called userGroup.getMembers().size() to detect changes, also forcing full collection loading. This resulted in SELECT members0_.usergroupid ... FROM usergroupmembers INNER JOIN userinfo executing twice, loading ~12,500 member rows each time (161.4ms total).

Combined, creating a single user took 4,083ms CPU time and allocated 2.7 GB of memory. During production loading, these figures were observed to be much higher.

  1. User deletion: UserRole and UserGroup member collection loading - Deleting a user triggered lazy initialization of the UserRole.members collection (110K+ users) for each role the user belonged to, via UserRoleDeletionHandler.deleteUser() calling role.getMembers().remove(user). Similarly, UserGroupDeletionHandler.deleteUser() iterated user.getGroups() and called userGroup.getMembers().remove(user), loading all members of each group.

Both deletion handlers now use direct SQL to manage the join tables via removeAllMemberships(), with updateLastUpdated calls to maintain timestamps and evict L1/L2 caches.

Additionally, the Hibernate mapping ownership for User↔UserRole was inverted to align with User↔UserGroup semantics: User.userRoles is now inverse="true" (non-owning) and UserRole.members is the owning side. Previously, User.userRoles was the owning side with cascade="all", which caused Hibernate to manage the join table through the User entity - triggering cascade-driven chain loading during user deletion.

The @Property(owner = TRUE) annotation override on getUserRoles() ensures the DHIS2 schema/import framework still treats it as an owner property, so that resetNonOwnerProperties() doesn't clear userRoles during PATCH/PUT and collectObjectReferences() still collects role references. The join table is now synced via SQL in UserObjectBundleHook.postCommit().

Solution

SchemaToDataFetcher

Added a filtered fetch(Schema, Collection) method that only queries for records matching unique property values being imported, using WHERE field IN (:values) clauses instead of loading all records. The old fetch(Schema) method was removed.

UserGroupService

Replaced Hibernate collection operations (userGroup.addUser()/removeUser()/getMembers().size()) with direct SQL via new UserGroupStore methods:

  • addMemberViaSQL(userGroupId, userId) — INSERT into usergroupmembers directly
  • removeMemberViaSQL(userGroupId, userId) — DELETE from usergroupmembers directly
  • updateLastUpdatedViaSQL(userGroupId, lastUpdatedBy) — UPDATE usergroup metadata with L1/L2 cache eviction
  • getMemberCounts(userGroupIds) — batch COUNT query for member counts without loading member entities

This avoids initializing the lazy members collection entirely.

The updateUserGroups method was rewritten to track membership changes via a changedGroups set rather than comparing getMembers().size() before and after.

canAddOrRemoveMember

Added an overload accepting UserGroup directly to avoid redundant database lookups when the caller already has the entity loaded. The original code called canAddOrRemoveMember(uid) (which fetched the group by UID), then immediately
called getUserGroup(uid) again.

Additional fixes

  • Replaced deprecated Class.newInstance() with getDeclaredConstructor().newInstance() in SchemaToDataFetcher
  • Reduced cognitive complexity in SchemaToDataFetcher.mapUniqueFields() by extracting helper methods

Results

Glowroot traces for creating a single user with group assignment on a copy of the database where this problem was originally observed.

Before After
SchemaToDataFetcher query 75.6ms -> 221,647 rows Eliminated
Member collection loading 161.4ms ->2 × ~12,500 rows Eliminated
CPU time 4,083ms 259ms
Allocated memory 2.7 GB 34.7 MB

Gatling simulation test was performed and the feature branch was consistently faster across the board. 2000 users were imported into the Sierra Leone database for this test.

Mean Response Time (ms)

Scenario Baseline Feature Diff Change
GET Users - userRoles expansion 185 150 -35 ⬇️ -18.9%
GET Users - userGroups expansion 187 169 -18 ⬇️ -9.6%
GET Users - query filter 208 180 -28 ⬇️ -13.5%
GET Users - organisationUnits expansion 230 196 -34 ⬇️ -14.8%
GET Users 153 133 -20 ⬇️ -13.1%
GET Users - large page size 1263 1015 -248 ⬇️ -19.6%
GET Users - combined common fields 341 244 -97 ⬇️ -28.4%
PUT User - full update 642 42 -600 ⬇️ -93.5%
PATCH User - partial update 684 52 -632 ⬇️ -92.4%
GET Users - all fields 836 619 -217 ⬇️ -26.0%
DELETE User - delete 2152 156 -1996 ⬇️ -92.8%
POST Metadata Import - single user 865 646 -219 ⬇️ -25.3%
POST User - create 867 646 -221 ⬇️ -25.5%

95th Percentile Response Time (ms)

Scenario Baseline Feature Diff Change
GET Users - userRoles expansion 261 171 -90 ⬇️ -34.5%
GET Users - userGroups expansion 257 215 -42 ⬇️ -16.3%
GET Users - query filter 284 221 -63 ⬇️ -22.2%
GET Users - organisationUnits expansion 291 227 -64 ⬇️ -22.0%
GET Users 214 149 -65 ⬇️ -30.4%
GET Users - large page size 1263 1015 -248 ⬇️ -19.6%
GET Users - combined common fields 341 244 -97 ⬇️ -28.4%
PUT User - full update 642 62 -580 ⬇️ -90.3%
PATCH User - partial update 684 98 -586 ⬇️ -85.7%
GET Users - all fields 836 619 -217 ⬇️ -26.0%
DELETE User - delete 2152 207 -1945 ⬇️ -90.4%
POST Metadata Import - single user 865 646 -219 ⬇️ -25.3%
POST User - create 867 646 -221 ⬇️ -25.5%

⬇️ = faster (improvement), ⬆️ = slower (regression)

Known limitation

The direct SQL operations bypass Hibernate's event lifecycle, which means PostCacheEventPublisher does not fire for these changes. In multi-instance deployments with Redis cache invalidation enabled (redis.cache.invalidation.enabled=ON), other nodes will not receive cache invalidation messages for usergroupm
embers changes. This will be addressed in a follow-up ticket. Single-instance deployments are unaffected — local L1/L2 caches are explicitly evicted.

Testing

All existing tests pass. Tested with Glowroot on a copy of the production database where the problem was originally observed.

AI Disclaimer:
Portions of the PR were developed with the assistance of AI.

@jason-p-pickering jason-p-pickering marked this pull request as draft February 10, 2026 12:02
@jason-p-pickering jason-p-pickering marked this pull request as ready for review February 10, 2026 16:06
@jason-p-pickering jason-p-pickering requested a review from a team February 10, 2026 16:08
@jason-p-pickering jason-p-pickering changed the title fix: Optimize user creation fix: Optimize user creation and deletion Feb 10, 2026
@jason-p-pickering jason-p-pickering marked this pull request as draft February 11, 2026 12:23
@jason-p-pickering jason-p-pickering force-pushed the fix-usergroup-member-loading branch from 618358a to bec4474 Compare February 12, 2026 14:35
@jason-p-pickering jason-p-pickering changed the title fix: Optimize user creation and deletion fix: Optimize user creation Feb 13, 2026
@jason-p-pickering jason-p-pickering changed the title fix: Optimize user creation fix: Optimize user creation and deletion Feb 17, 2026
@jason-p-pickering jason-p-pickering marked this pull request as ready for review February 17, 2026 10:32
@jason-p-pickering jason-p-pickering requested review from a team and jbee February 17, 2026 10:32
@jason-p-pickering jason-p-pickering changed the title fix: Optimize user creation and deletion perf: Optimize user creation and deletion Feb 17, 2026
@sonarqubecloud
Copy link

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.

3 participants

Comments