Skip to content

fix: Do not load unloaded collections when building metadata audit entries [DHIS2-22235] (2.43) - #25535

Open
jason-p-pickering wants to merge 2 commits into
2.43from
fix/DHIS2-22235-audit-skip-unloaded-collections-2.43
Open

jason-p-pickering wants to merge 2 commits into
2.43from
fix/DHIS2-22235-audit-skip-unloaded-collections-2.43

Conversation

@jason-p-pickering

@jason-p-pickering jason-p-pickering commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Backport of #25534 to 2.43, including the review fix (loaded empty collections written as []). AbstractHibernateListener differs from master only in master's later shouldRefetchThroughProxy change (#25014), which is not on 2.43. The measurements below were taken on a 2.43 build.

Summary

AbstractHibernateListener.createAuditEntry treated an unloaded PersistentCollection like an
entity proxy: it read the property through a proxy and called isEmpty() on it, which loaded the
whole collection after commit (through a temporary session) just to write its UIDs into the audit
entry. This breaks the method's own Javadoc, rule 2: "Do not include any lazy HibernateProxy or
PersistentCollection that is not loaded."

With the default audit configuration (audit.metadata/audit.logger unset), every update of a
data set or org unit group, even a sharing-only change, loaded all of its org units and wrote a
multi-MB audit entry.

Fix

Skip a PersistentCollection that is not wasInitialized() before the proxy branch. Collections
the request did load are still written as UIDs.

A loaded collection that is empty (e.g. the request removed every member) is now written as [].
Before, empty collections were dropped from the entry, which with this change would have made "emptied
the collection" and "never loaded it" look the same (review feedback). So in the entry:

Collection Written as
not loaded by the request key absent
loaded, empty []
loaded, with items list of UIDs

This matches the delete path, which already writes empty collections. Nothing else changes: which
objects and events are audited, the entry format, or any scalar field.

Measured (copy of a 2.43 production database, same build, sharing change only)

Request Org units Before After
sharing, OrganisationUnitGroup 137,865 2.8–3.3 s, 340k–416k DB rows, 1.93 MB entry 0.03–0.16 s, 1.3k–2k rows, 673 B
sharing, DataSet 137,877 2.7 s, 319k–537k rows, 1.95 MB entry 0.03–0.04 s, 0.5k–0.8k rows, 1.2 KB
sharing, Program (comparison) 240,910 0.04–0.13 s, 1.1 KB unchanged

POST/DELETE /api/organisationUnits/{ou}/dataSets/{ds} still writes the full set (1.93 MB): that
request loads the set before the audit runs, so it is a loaded collection and is audited as
before. Removing that load is a separate follow-up.

On the production system, one day's audit log was 3.4 GB, 96% of it Program/DataSet UPDATE entries
of 1.5–2.25 MB, made up of org unit UID lists.

Tests

AuditHibernateListenerTest:

  • testUnloadedCollectionIsNotLoadedOrIncluded: uses a real PersistentSet without a session.
    On the unfixed code this fails with LazyInitializationException (the listener tries to load
    it). With the fix, the set stays unloaded and has no key in the entry.
  • testLoadedCollectionIsIncludedAsUids: a loaded set is still written as UIDs.
  • testLoadedEmptyCollectionIsIncludedAsEmptyList: a loaded empty set is written as [] (fails before the
    review fix: the key was missing).

Jira: DHIS2-22235

🤖 AI Assisted

…tries [DHIS2-22235] (2.43)

createAuditEntry treated an unloaded PersistentCollection like an entity
proxy and read it, so every update of a data set or org unit group loaded
all of its org units after commit just to list their UIDs in the audit
entry. This breaks the method's documented rule 2 ("Do not include any lazy
HibernateProxy or PersistentCollection that is not loaded"). A collection
the request did not load is now left out; loaded collections are still
written as before.

On a copy of a production database, changing only the sharing of a data
set or org unit group with ~138k org units went from 2.7-3.3 s and a
~1.9 MB audit entry to 0.03-0.16 s and ~1 KB.

Backport of #25534.
(cherry picked from commit 786822a)

🤖 AI Assisted
…DHIS2-22235] (2.43)

Review feedback: with unloaded collections left out, a loaded collection that the request
emptied was also missing from the entry (putValueToMap dropped empty collections), so
"removed every org unit" and "changed only the sharing" produced the same entry. A loaded
empty collection is now written as [], so a missing key only means "not loaded". This
matches the delete path, which already writes empty collections.

Also moves the unloaded-collection check into a helper to keep createAuditEntry within
the cognitive complexity limit.

(cherry picked from commit 8bb35d3)

🤖 AI Assisted
@sonarqubecloud

sonarqubecloud Bot commented Oct 7, 2026

Copy link
Copy Markdown

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.

1 participant