[#11088] feat(authz): Route isSelf(ROLE) through cache + batch user/group version probes#11174
Draft
yuqi1129 wants to merge 37 commits into
Draft
[#11088] feat(authz): Route isSelf(ROLE) through cache + batch user/group version probes#11174yuqi1129 wants to merge 37 commits into
yuqi1129 wants to merge 37 commits into
Conversation
… JcasbinAuthorizer caches Introduces the HA invalidation infrastructure that will later carry the version-validated role caching (tracked in apache#10772). This change leaves the existing role-loading path on its current TTL-only behavior. Changes ------- * `JcasbinAuthorizationLookups`: two-tier metadata-id and owner lookup facade (per-request dedup via AuthorizationRequestContext, shared Caffeine-backed GravitinoCache fallback, DB on miss). Owners are now fetched directly from `owner_meta` via OwnerMetaMapper instead of the OWNER_REL relation query, so we have a metadataId-keyed cache that matches the change-log key space. * `JcasbinChangePoller`: scheduled thread that drains `entity_change_log` and `owner_meta` change rows since a high-water cursor and invalidates the affected `metadataIdCache` / `ownerRelCache` keys. Documents the id-cursor in-flight-commit trade-off and the writer-side pre-mutation-name contract. * `JcasbinAuthorizer`: - wires the two GravitinoCaches, the lookups facade, and the poller in `initialize()` / `close()`; - routes `isOwner`, `authorizeByJcasbin` (OWNER path) and `handleMetadataOwnerChange` through the lookups; - drops the old in-class `OwnerInfo` (replaced by `org.apache.gravitino.storage.relational.po.auth.OwnerInfo`) and the `loadOwnerPolicy` / `checkOwnership` pair, collapsed into a single `ownerMatchesUserOrGroups` helper that consumes the resolved `Optional<OwnerInfo>`; - implements `handleEntityStructuralChange` to invalidate (or prefix invalidate) `metadataIdCache` on rename / drop. * `GravitinoAuthorizer`: adds the `handleEntityStructuralChange` default method so callers can wire the new hook without breaking existing implementations. * `Configs`: adds `metadataIdCacheSize` and `changePollIntervalSecs`. The version-validated role caches and the JcasbinRoleLoader rewrite ride on top of this change in a follow-up PR. Test plan --------- `./gradlew :server-common:test --tests 'org.apache.gravitino.server.authorization.*' -PskipITs` `./gradlew :server-common:javadoc :core:javadoc` Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…nAuthorizer Builds on top of apache#11117 (eventual-consistency invalidation): adds the strong-consistency role-loading layer. With this PR landed, every cache in JcasbinAuthorizer has an explicit consistency story. What lands here --------------- - `CachedUserRoles` / `CachedGroupRoles`: small immutable snapshots carrying the {role ids, source updated_at} pair used as the staleness sentinel. - `JcasbinLoadedRolesCache`: extracts the previously-inline `LoadedRolesCache` to its own file. Wraps a Caffeine cache with a synchronous removal listener so that `loadedRoles` eviction always flushes the role's JCasbin policies from both enforcers. - `JcasbinAuthorizer` now: - keeps `userRoleCache` and `groupRoleCache` version-validated against `user_meta.updated_at` / `group_meta.updated_at` (probes per request via `loadUserInfo` / `loadGroupInfo`, with per-request dedup through `AuthorizationRequestContext`); - changes `loadedRoles` from `<Long, Boolean>` to `<Long, Long>` (storing `role_meta.updated_at`) so role-policy reloads happen on DB-version change, not TTL expiry; - replaces the `Executor` + `CompletableFuture` async role-load path with a synchronous 4-step `loadRolePrivilege`: 1. version-validated user-direct roles 2. version-validated group-inherited roles for every group in the current `UserPrincipal` 3. prune stale g-rows (IdP group removal / role unassignment) 4. batch `role_meta` version check + reload of stale policies - reuses `loadUserInfo`'s per-request cache from `isMetalakeUser` / `isOwner` so a single HTTP request never re-queries `user_meta`. - `Configs.GRAVITINO_AUTHORIZATION_ROLE_CACHE_SIZE` doc now notes that the same value sizes user-role, group-role and loaded-role caches. - Lombok wired in `server-common/build.gradle.kts` for the cached snapshot POJOs. Test plan --------- `./gradlew :server-common:test --tests 'org.apache.gravitino.server.authorization.*' -PskipITs` `./gradlew :server-common:javadoc :core:javadoc` Tests cover: version-validated user-role cache, group-inherited role, stale group skipped, group-role revocation, multi-group partial revocation, IdP group removal pruning g-rows, deny-wins over group-inherited allow, role-shared-by-user-and-group survives single-side revocation, plus the existing owner, isSelf, and hasMetadataPrivilegePermission flows. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…29/gravitino into feat/jcasbin-eventual-consistency
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Isolate JcasbinAuthorizer close-test so it does not tear down the shared poller/caches used by sibling tests. - Preserve interrupt status in JcasbinChangePoller so shutdownNow stops the poll cycle promptly. - Split IOException from RuntimeException in OwnerManager owner-change notification and log full context. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…che-refactor Redo of merge 42b4ca7 which dropped the version-validated cache wiring in JcasbinAuthorizer (userRoleCache, groupRoleCache, loadUserInfo, loadUserRoles, loadGroupRoles) when auto-resolving conflicts to the eventual-consistency side. This re-merge preserves the cache wiring from 62ae012 and applies the EC-side changes on top: - TimeUnit.SECONDS.toMillis overflow fix on cacheExpirationSecs - handleMetadataOwnerChange try/catch around MetadataIdConverter.getID - handleEntityStructuralChange -> handleEntityNameIdMappingChange rename - isNonLeaf -> isContainerType rename - Poller-as-primary-HA-invalidation explanatory comment - metadataIdCache javadoc rewording (Hierarchical -> Path-based) Dropped from EC side as no-longer-applicable: - testCloseAwaitsRoleLoadExecutorTermination + RecordingThreadPoolExecutor (cache-refactor removed the executor field entirely; the test asserted behavior of a field that no longer exists) - @afterall close(jcasbinAuthorizer) block (now per-test via @AfterEach) Verified with ./gradlew :server-common:test --tests 'org.apache.gravitino.server.authorization.jcasbin.*': 37 tests pass (9 lookup, 21 authorizer, 2 cache helpers, 5 change poller). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
On the JcasbinAuthorizer hot path with all caches warm, the per-request
version probes for the user and for each of the user's groups previously
ran as 1 + N separate SELECTs against user_meta / group_meta. Collapse
them into a single UNION query.
- New AuthSubjectVersion POJO carries one row of the UNION result
(subjectType + id + name + updated_at), so the caller can split the
flat result back into per-subject snapshots.
- New UserMetaMapper.batchGetUserAndGroupUpdatedAt mapper method with a
single base SQL provider (no per-backend override). Empty groupNames
degrades to a user-only SELECT, avoiding an empty IN clause across
H2/MySQL/PostgreSQL.
- JcasbinAuthorizer.prefetchUserAndGroupInfo runs the batch once at the
top of loadPrivilegeAndAuthorize and primes both the per-request
userInfoCache and groupInfoCache via computeXxxIfAbsent. Subsequent
loadUserInfo / loadGroupInfo calls in the same request hit the
per-request cache (0 DB queries), so loadRolePrivilege only spends 1
extra batchGetRoleUpdatedAt round trip.
For N groups, the hot path drops from N+2 queries to 2.
Tests: 37/37 server-common jcasbin tests pass; new
TestUserMetaService.batchGetUserAndGroupUpdatedAt covers user+groups,
empty groups, partial-missing, and all-missing scenarios on H2 (10/10).
The MySQL/PG branches of the parameterized test could not be exercised
in this environment due to local testcontainers infra issues; the SQL
mirrors the existing batchGetRoleUpdatedAt pattern that already works
across all three backends in CI.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…lidated role caches
The isSelf(ROLE) branch was the last remaining call site that bypassed
the version-validated userRoleCache / groupRoleCache and went straight
to entityStore.relationOperations().listEntitiesByRelation(ROLE_USER_REL)
plus a batchGet over the user's groups. This left a 2-query (or more,
with groups) DB hit on every authorization-expression evaluation that
referenced isSelf for a ROLE — even immediately after authorize() had
already loaded the same role list via the cache.
Switch the ROLE branch to:
1. loadUserInfo to get user_id + updated_at,
2. loadUserRoles for the direct role list (cached, version-validated),
3. loadGroupRoles per group from the principal (same cache path).
Repeated isSelf(ROLE) calls in the same request — and isSelf following
an authorize() — now resolve the role list from the process-wide
Caffeine cache instead of re-querying user_role_rel / group_meta.
isSelf does not take an AuthorizationRequestContext parameter, so the
implementation allocates a fresh per-call context. This scopes only the
lightweight version probes (getUserUpdatedAt / getGroupUpdatedAt); the
role-list queries the issue calls out are still deduplicated across all
calls by the shared cache, which is what the acceptance criterion asks
for. Changing the interface to thread the context through would
ripple into GravitinoAuthorizer, PassThroughAuthorizer, and the
AuthorizationExpressionConverter template; deferred as a follow-up.
Side effect note: loadUserRoles / loadGroupRoles call bindUserRoles
which writes g-rows into the JCasbin enforcer. This is the same
binding authorize() would do for the same user, so subsequent
authorize() calls in the same request remain correct.
Tests:
- testIsSelfRoleReusesCacheAcrossCalls — two isSelf(ROLE) calls,
listRolesByUserId invoked exactly once (apache#11088 AC).
- testIsSelfRoleDoesNotCallListEntitiesByRelation — asserts the old
EntityStore.relationOperations() bypass is gone.
- All 21 existing TestJcasbinAuthorizer cases still pass
(testIsSelfRoleViaGroup unchanged).
Fix: apache#11088
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR optimizes JCasbin-based authorization by reducing DB fan-out on hot paths and ensuring isSelf(ROLE) reuses the same version-validated caches used by authorize()/isOwner(). It also introduces stronger cache invalidation semantics (atomic invalidation batches) and adds a background change poller to keep metadata/owner caches coherent in multi-node deployments.
Changes:
- Batch user+group
updated_atprobes into a single UNION query and prefetch per-request user/group version info. - Route
isSelf(ROLE)through version-validateduserRoleCache/groupRoleCacheinstead of bypassing caches via EntityStore relations. - Add
GravitinoCache.get(...)+runInvalidationBatch(...)and update cache implementations/tests; add change poller + new config/documentation.
Reviewed changes
Copilot reviewed 27 out of 27 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java | Core refactor: version-validated user/group role caches, batched version probes, isSelf(ROLE) cache path, poller-backed metadata/owner lookup usage. |
| server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationLookups.java | Two-tier (request + shared cache) metadata-id and owner resolution helpers. |
| server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinChangePoller.java | Background poller to invalidate metadataId/owner caches from DB change logs. |
| server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinLoadedRolesCache.java | Loaded-role cache with synchronous removal listener to keep enforcer policies in sync. |
| server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedUserRoles.java | Cached snapshot for user-direct role IDs with version sentinel. |
| server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/CachedGroupRoles.java | Cached snapshot for group-inherited role IDs with version sentinel. |
| server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java | Expanded unit tests for cache behavior and updated mocks for mapper-driven role loading. |
| server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizationLookups.java | Unit tests for key building and request/cache dedup semantics. |
| server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinChangePoller.java | Unit tests for poller helper behavior and synchronization guarantees. |
| server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizerCacheHelpers.java | Unit tests for cached snapshot POJOs. |
| server-common/build.gradle.kts | Add Lombok processor/compileOnly deps for new Lombok-annotated classes in this module. |
| core/src/main/java/org/apache/gravitino/cache/GravitinoCache.java | Add get(key, loader) and runInvalidationBatch API for atomic read/load and invalidation batching. |
| core/src/main/java/org/apache/gravitino/cache/CaffeineGravitinoCache.java | Implement atomic loader-backed gets and lock-serialized invalidations (incl. batched invalidation). |
| core/src/main/java/org/apache/gravitino/cache/NoOpsGravitinoCache.java | Implement get(key, loader) with non-null enforcement. |
| core/src/test/java/org/apache/gravitino/cache/TestGravitinoCache.java | Add concurrency tests for atomic load, invalidate-vs-reader ordering, and batched invalidation. |
| core/src/main/java/org/apache/gravitino/storage/relational/po/auth/AuthSubjectVersion.java | POJO representing a row from the batched user+group version probe UNION query. |
| core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaMapper.java | Add batchGetUserAndGroupUpdatedAt mapper API. |
| core/src/main/java/org/apache/gravitino/storage/relational/mapper/UserMetaSQLProviderFactory.java | Wire SQL provider for batched user+group version probes. |
| core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/UserMetaBaseSQLProvider.java | Implement UNION ALL query with empty-group special-casing for portability. |
| core/src/test/java/org/apache/gravitino/storage/relational/service/TestUserMetaService.java | Integration test coverage for batched user+group version probe behavior. |
| core/src/main/java/org/apache/gravitino/Configs.java | Add config entries for metadataId cache size and change poll interval; clarify role cache sizing implications. |
| docs/security/access-control.md | Document new JCasbin cache/poller configs. |
| core/src/main/java/org/apache/gravitino/authorization/GravitinoAuthorizer.java | Allow handleMetadataOwnerChange to receive null oldOwnerId; add name→id mapping invalidation hook API. |
| core/src/main/java/org/apache/gravitino/authorization/OwnerManager.java | Notify authorizer on initial owner set; best-effort error handling for hook invocation. |
| core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java | Notify authorizer on rename to invalidate name→id mapping cache keys. |
| core/src/test/java/org/apache/gravitino/authorization/TestOwnerManager.java | Verify initial owner set triggers authorizer hook with null oldOwnerId. |
| core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java | Verify rename triggers name→id mapping invalidation hook. |
Comment on lines
+768
to
+772
| if (cachedOpt.isPresent() && cachedOpt.get().getUpdatedAt() >= userInfo.getUpdatedAt()) { | ||
| // Cache is still valid | ||
| CachedUserRoles cached = cachedOpt.get(); | ||
| bindUserRoles(userId, cached.getRoleIds()); | ||
| return cached.getRoleIds(); |
Comment on lines
+818
to
+821
| if (cachedOpt.isPresent() && cachedOpt.get().getUpdatedAt() >= groupInfo.getUpdatedAt()) { | ||
| CachedGroupRoles cached = cachedOpt.get(); | ||
| bindUserRoles(userId, cached.getRoleIds()); | ||
| return cached.getRoleIds(); |
|
|
||
| @Override | ||
| public void invalidateByPrefix(String prefix) { | ||
| cache.asMap().keySet().removeIf(k -> k.toString().startsWith(prefix)); |
Comment on lines
851
to
855
| /** | ||
| * Resolves GroupEntity objects for the current principal's groups, skipping any that are stale or | ||
| * not found in the store. | ||
| * not found in the store. Used by both {@link #isSelf} (ROLE branch) and {@link | ||
| * #loadRolePrivilege} to discover group-inherited role assignments. | ||
| */ |
Comment on lines
+128
to
+132
| // Invalidator should be parked on the write lock until the reader releases its read lock. | ||
| Thread.sleep(150); | ||
| Assertions.assertFalse( | ||
| invalidator.isDone(), "invalidate must not proceed while a reader holds the read lock"); | ||
|
|
Comment on lines
+177
to
+179
| Future<Optional<Long>> reader = executorService.submit(() -> cache.getIfPresent("k2")); | ||
| Thread.sleep(150); | ||
| Assertions.assertFalse(reader.isDone(), "reader must wait for the batch to release the lock"); |
…factor Conflicts in JcasbinAuthorizationLookups.java and JcasbinAuthorizer.java are resolved by composing the two parallel changes: - Take origin/main's hierarchical-schema refactor of `isOwner` / `internalAuthorize` (apache#9970): null-check on metadataObject, walk the schema inheritance chain via the new `buildSchemaInheritanceChain`, and the extracted `isOwnerOfObject` / `authorizeObject` / `authorizeByJcasbin` decomposition. - Take origin/main's `Optional<Long> resolveMetadataId` + negative-cache- avoiding `loadMetadataId` so missing objects are never cached. - Keep this branch's version-validated cache layer: `userRoleCache`, `groupRoleCache`, `loadedRoles`, the `loadUserInfo` / `loadGroupInfo` per-request dedup, and the 4-step `loadRolePrivilege` + `versionCheckAndLoadRoles` path. These replace the previous async addRoleForUserAndLoadPolicies pipeline. - Adapt `ownerMatchesUserOrGroups` to the new `Principal`-based signature from origin/main while routing the user-id lookup through this branch's cached `loadUserInfo` (instead of the direct `getUserEntity` call), so the hot path still hits the version-validated cache. - Drop the now-redundant `resolveFreshMetadataId` in favour of origin/main's inline `MetadataIdConverter.getID(...).ifPresent(...)`. All four jcasbin test classes pass post-merge. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Brings in the latest cache-refactor work (centralized JcasbinAuthorizationCacheKeys,
Optional<Long> from MetadataIdConverter.getID, hierarchical-schema authorize
decomposition, eventual-consistency invalidation, plus several main merges).
Conflict resolution in JcasbinAuthorizer.java:
- Removed local KEY_SEP field; rewrote prefetchUserAndGroupInfo to use the
centralized JcasbinAuthorizationCacheKeys.userRoleKey / groupRoleKey so the
per-request userInfoCache / groupInfoCache it primes share keys with the
single-subject loadUserInfo / loadGroupInfo helpers (no behavior change).
- Kept the SUBJECT_TYPE_USER / SUBJECT_TYPE_GROUP constants for the UNION
result split.
- Reconciled isSelf(ROLE): adapted to the new Optional<Long> return type of
MetadataIdConverter.getID by unwrapping via isPresent() / get() while
keeping the version-validated cache routing (loadUserInfo + loadUserRoles
+ per-group loadGroupRoles) introduced by this PR.
- Kept remote's reworded javadoc for ownerMatchesUserOrGroups.
Tests: 43/43 jcasbin tests pass
(9 cacheKeys + 2 lookups + 25 authorizer + 2 cacheHelpers + 5 poller).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Code Coverage Report
Files
|
# Conflicts: # server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java
# Conflicts: # server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizationLookups.java # server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java # server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changes were proposed in this pull request?
Two related changes:
Commit 1 — batch user + group version probes (
039bdd7d3)On the
authorize()hot path with all caches warm, the per-request versionprobes for the current user + each of the user's groups ran as
1 + Nseparate
getUserUpdatedAt/getGroupUpdatedAtSELECTs. Collapse theminto a single
UNION ALLquery:AuthSubjectVersionPOJO carrying one row of the UNION result.UserMetaMapper.batchGetUserAndGroupUpdatedAtmapper method, onebase SQL provider, no per-backend override; empty
groupNamesdegrades to a user-only SELECT so we avoid an empty IN clause across
H2 / MySQL / PostgreSQL.
JcasbinAuthorizer.prefetchUserAndGroupInforuns the batch onceat the top of
loadPrivilegeAndAuthorizeand primes both theper-request
userInfoCacheandgroupInfoCacheviacomputeXxxIfAbsent. SubsequentloadUserInfo/loadGroupInfocalls in the same request hit the per-request cache (0 DB queries).
For N groups, hot path drops from
N+2queries to2.Commit 2 — route
isSelf(ROLE)through the cache (232053523, fixes #11088)isSelf(ROLE)was the last call site bypassing the version-validateduserRoleCache/groupRoleCacheand going straight toentityStore.relationOperations().listEntitiesByRelation(ROLE_USER_REL)plus a batchGet over the user's groups. Switch the ROLE branch to:
loadUserInfoforuser_id+updated_at,loadUserRolesfor the direct role list (cached, version-validated),loadGroupRolesper group from the principal (same cache path).Repeated
isSelf(ROLE)calls in the same request — andisSelffollowingauthorize()— now resolve the role list from the process-wide cacheinstead of re-querying
user_role_rel/group_meta.Why are the changes needed?
isSelf(ROLE)is invoked fromAuthorizationExpressionEvaluatorforevery authorization expression that references role identity, so the
redundant role-list fetch landed on every such request. Issue #11088
called out the inconsistency with
authorize()/isOwner(), whichwere already using the cache. The batch-probe optimization in commit 1
is the prerequisite that makes the per-request user + group fan-out
cheap enough to also serve repeated
isSelfcalls without measurableoverhead.
isSelfdoes not take anAuthorizationRequestContext, so the newimplementation allocates a fresh per-call context. This scopes only the
lightweight version probes (
getUserUpdatedAt/getGroupUpdatedAt);the role-list queries the issue calls out are still deduplicated across
all calls by the shared cache, which is what the acceptance criterion
asks for. Threading the context through would ripple into the
GravitinoAuthorizerinterface,PassThroughAuthorizer, and theAuthorizationExpressionConvertertemplate; left as a follow-up.Fix: #11088
Does this PR introduce any user-facing change?
No.
How was this patch tested?
./gradlew :server-common:test --tests 'org.apache.gravitino.server.authorization.jcasbin.*'— 39/39 pass (9 lookup + 23 authorizer + 2 cacheHelpers + 5 poller).testIsSelfRoleReusesCacheAcrossCallsasserts that twoisSelf(ROLE)calls in a row issue exactly onelistRolesByUserId(the [Improvement] Route isSelf(ROLE) through the version-validated userRoleCache #11088 AC).testIsSelfRoleDoesNotCallListEntitiesByRelationasserts the old EntityStore bypass is gone.TestUserMetaService.batchGetUserAndGroupUpdatedAtIT covers user-only, user+groups, partial-missing, and all-missing scenarios on H2 (10/10). The MySQL/PG branches of the parameterized test could not be exercised in my local environment due to testcontainers setup issues; the SQL mirrors the existingbatchGetRoleUpdatedAtpattern that already works across all three backends in CI.