Hi there 👋
| 🌥️ Tech |
🌤️ Feat |
| 2 high, 4 medium, 2 low |
1 issue |
This merge request addresses Keycloak #40368, an NPE raised when a group is accessed concurrently with its deletion. The core fix in GroupAdapter.getSubGroupsCount() guards against a null lazily-loaded model, and the associated cache-layer changes (@Override on CachedGroup.getRealm(), thread-safe DefaultLazyLoader) are correct. However, the fix is incomplete: sibling getSubGroupsStream(...) overloads still dereference the model unguarded and can throw the same NPE, and the change to getSubGroupsCount() now violates the GroupModel "never returns null" contract, trading the original NPE for a contract violation and potential downstream unboxing NPE. The new integration test has several weaknesses (reader thread not joined, no synchronization to guarantee overlap, only top-level groups covered) that make it an unreliable regression guard and leave parent-chain deletion races uncovered. Additionally, LazyModel is not thread-safe, which is relevant to the PR's stated purpose. Overall, the MR needs further hardening before merge.
These might need a close look
- 🔴 org/keycloak/models/cache/infinispan/GroupAdapter.java (L231)
Incomplete NPE fix: only getSubGroupsCount() is guarded. The three getSubGroupsStream(...) overloads (lines 231, 236, 241) still call modelSupplier.get() without a null check and will throw an NPE under the same concurrent-deletion race. The no-arg getSubGroupsStream() (line ~218) already handles this by iterating cached.getSubGroups(...), which falls back to an empty set. Apply the same pattern or null-guard the overloads consistently.
- 🔴 org/keycloak/models/cache/infinispan/GroupAdapter.java (L275)
getSubGroupsCount() now returns null when the model is null, violating the GroupModel contract ("Never returns null"). GroupUtils.populateSubGroupCount passes the null straight into GroupRepresentation, silently dropping the value, and any unboxing caller would get an NPE. Recommend returning a safe non-null value, e.g., counting from the cached snapshot: (long) cached.getSubGroups(keycloakSession, modelSupplier).size().
Worth checking
- 🟡 org/keycloak/models/cache/infinispan/GroupAdapter.java (L42)
modelSupplier = new LazyModel<>(this::getGroupModel) uses LazyModel, which caches the resolved model in a plain non-volatile field with no synchronization. This is a data race and can also cache a null result after concurrent deletion, which the new null-guard depends on. While the adapter is typically created per RealmCacheSession, the PR's concurrency-fix intent warrants making LazyModel thread-safe (volatile + double-checked locking) or documenting why it is guaranteed single-threaded.
- 🟡 org/keycloak/tests/admin/group/GroupTest.java (L162)
The reader thread is started and the main thread asserts caughtExceptions is empty without ever joining the thread. deletedAll is only checked at the loop top, so an in-flight exception appended after the assertion is silently missed, producing a false negative. Join the thread (with a timeout) before the assertion and handle InterruptedException.
- 🟡 org/keycloak/tests/admin/group/GroupTest.java (L145)
The reader spins in a busy-loop with no initial synchronization, so nothing guarantees the reader has actually started or is in-flight when deletion begins. If the reader is slow to schedule, deletions may complete first and the test passes trivially without reproducing the race. Use a CountDownLatch/barrier to ensure overlap, plus a short sleep in the loop to reduce server load.
- 🟡 org/keycloak/utils/GroupUtils.java (L44)
In populateGroupHierarchyFromSubGroups, session.groups().getGroupById(realm, currGroup.getParentId()) can return null if the parent is concurrently deleted, and there is no null check before groupEvaluator.canView(parentModel) and toRepresentation(...). This pre-existing path is not changed by this MR and is not covered by the new test. If the concurrency hardening is meant to cover hierarchy building, guard against a null parent.
Small things (take or leave)
- 🔵 org/keycloak/tests/admin/group/GroupTest.java (L124)
All 100 groups are created as top-level groups (via managedRealm.admin().groups().add(group)), so the while (currGroup.getParentId() != null) loop in GroupUtils.populateGroupHierarchyFromSubGroups is never entered. The parent-chain traversal, parent lookups, and computeIfAbsent/merge logic are not exercised under concurrent deletion. Consider adding a hierarchy with nested subgroups and having the reader search for subgroups to cover these paths.
- 🔵 org/keycloak/tests/admin/group/GroupTest.java (L126)
List<String> groupUuuids is misspelled (triple 'u'). Minor readability fix.
✅ Feature-level checklist
Not there yet
- ❌ Fix the NPE when accessing a group concurrently (per PR description closing #40368).
The fix is incomplete: sibling getSubGroupsStream overloads still throw the same NPE, and getSubGroupsCount() now returns null, violating the GroupModel contract and introducing a potential downstream NPE. The new test does not reliably reproduce the race or cover parent-chain deletion paths, so the requirement is not fully satisfied.
FriendlyReviewer found both golden bugs. On
getSubGroupsCount(), the review frames the fix as trading one NPE for another: the null now violates theGroupModel"never returns null" contract and becomes a downstream unboxing NPE once passed throughGroupUtils.populateSubGroupCountintoGroupRepresentation, with a concrete alternative (counting from the cached snapshot). On the test, it identifies the unjoined reader thread and the loop-top-only check ofdeletedAllas the false-negative mechanism.The strongest supplementary finding is arguably the most important item on the page: the fix is incomplete. Only
getSubGroupsCount()is guarded; the threegetSubGroupsStream(...)overloads still callmodelSupplier.get()unguarded and throw the same NPE under the same race. The test-quality findings reinforce the point — the busy-loop has no synchronization guaranteeing overlap, so the test can pass trivially, and with only top-level groups the parent-chain paths are never exercised. A regression in the sibling accessors would not be caught.Two caveats. Severity is arguably inverted between the two HIGH items: the incomplete-fix finding (GroupAdapter.java L231) is the actual remaining crash path and could be considered the more severe of the two, while the golden contract violation (L275) is a latent, downstream failure. And two findings are conditional by nature — the
LazyModelthread-safety point matters only if adapters outlive their session, and theGroupUtilsnull-parent path is pre-existing and outside this PR's diff. The "Not there yet" checklist verdict is consistent with the review's own evidence.