Repository navigation
Conversation
|
@hu-ahmed could you have a brief look on this optimisation as well? |
hu-ahmed
left a comment
There was a problem hiding this comment.
Nice change, and the safety homework is genuinely done — I verified the parts that would matter most: no time-dependent evaluation anywhere in the tree enforcer (so frozen verdicts can't go stale), DefaultEffectedSubjects and ImmutablePermissions really are immutable so sharing the cached instance is safe, and getSubjectsWithPartialPermission correctly keeps its mutable-set contract. Full policies/model suite green for me too (1057/0), plus policies/enforcement (268/0). No correctness concerns — verdicts are identical to the non-memoized path.
Two things I'd like to see addressed.
1. A cache hit still does most of the work the memo was meant to skip
All four memoized methods build the resource pointer before consulting the memo, and the two permission checks additionally re-derive the subject-id set. The pointer isn't part of the key and is unused on a hit, and AuthorizationContext.getAuthorizationSubjectIds() already memoizes the ids (ImmutableAuthorizationContext:133-147) — so the stream-and-collect is redundant.
Measured per call (ThreadMXBean.getCurrentThreadAllocatedBytes, 200k iterations after 50k warmup):
hasUnrestrictedPermissions (memo hit) as-is with patch
10-label policy, 1 caller subject 351.0 ns / 1373 B 41.9 ns / 88 B
100-label policy, 1 caller subject 304.2 ns / 1385 B 51.0 ns / 121 B
100-label policy, 5 caller subjects 353.8 ns / 1528 B 61.2 ns / 152 B
Attributing the eager work:
createAbsoluteResourcePointer 77-108 ns / ~730 B <- unused on a hit
stream -> HashSet (current) 51- 62 ns / ~385 B
ctx.getAuthorizationSubjectIds() (memoized) 4.5 ns / 0 B <- already cached
The fix is to key the memo on the already-memoized ID list, and build both the resource pointer and the HashSet inside the supplier so they are paid only on a miss. The set does matter: CheckPermissionsVisitor.visitSubjectNode calls authSubjectIds.contains(...) once per subject node in the tree, so handing the visitor a List instead would turn the miss path from O(policy subjects) into O(policy subjects x caller subjects). That's 14 insertions / 12 deletions, full 1057-test suite still green — happy to push it.
One consequence worth naming: the key becomes order-dependent (list equality), so two contexts holding the same subjects in a different order get separate entries. Verdicts are unaffected and the order is stable per caller in practice, but the PermissionCheckKey javadoc currently advertises order-independence and would need rewording.
Minor, related: the code comment calls this eager work "cheap" — it measures ~1.1 KB of the ~1.4 KB allocated per hit.
2. authorization-memo-cache-max-size = 0 doesn't fully disable the memo
The configured value is only threaded through PolicyEnforcer:273. The other defaultEvaluator call sites (:132, :215, :242, :355, :382, :503) use the hardcoded DEFAULT_MAX_MEMO_SIZE.
:503 is the one that matters: it builds the namespace-filtered enforcer, which is what ThingEnforcerActor enforces against on the things-service hot path. So the documented escape hatch — "Set to 0 to disable entirely - then no ConcurrentHashMaps are allocated and every check walks the tree directly" — does not hold for the very instance an operator would most likely be trying to disable it on.
Separately, (int) authorizationMemoMaxSize at :273 wraps negative for a configured value above Integer.MAX_VALUE, silently disabling the memo instead of enabling a large one. Worth clamping.
…ound Review feedback on PR eclipse-ditto#2514 (thanks @hu-ahmed) raised two issues, both confirmed. 1. A memo hit still did most of the work the memo was meant to skip. All four memoized methods built the absolute resource pointer *before* consulting the memo, and the two permission checks additionally re-derived the subject-id set by streaming the AuthorizationContext. The pointer is not part of either key and is unused on a hit, and AuthorizationContext.getAuthorizationSubjectIds() already memoizes the ids (ImmutableAuthorizationContext), so the stream-and-collect was redundant. Key the permission-check memo on the context's already-memoized id List and build both the resource pointer and the subject-id HashSet inside the memo supplier, so they are paid only on a miss. The Set is still handed to the visitor on the miss path: CheckPermissionsVisitor probes it with contains() once per subject node, so passing the List would turn the walk into O(policy subjects x caller subjects). getSubjectsWith[Partial]Permission likewise build their pointer inside the supplier. Measured memo-hit cost (ThreadMXBean.getCurrentThreadAllocatedBytes, 200k iterations after 50k warmup): before after 10-label policy, 1 caller subject 496 ns / 1366 B 52 ns / 88 B 100-label policy, 1 caller subject 460 ns / 1396 B 64 ns / 121 B 100-label policy, 5 caller subjects 404 ns / 1528 B 72 ns / 152 B Consequence, now documented on PermissionCheckKey: list equality makes the key order-dependent, so two contexts holding the same subjects in a different order occupy separate entries. Verdicts are unaffected (each entry is computed from its own ids) and the order is stable per caller. The javadoc claim of order-independence and the comment calling the eager work "cheap" are both gone - that work was ~1.1 KB of the ~1.4 KB allocated per hit. 2. authorization-memo-cache-max-size = 0 did not fully disable the memo. The configured value only reached the enforcer built by withResolvedImportsAndNamespacePolicies; the other defaultEvaluator call sites used the hardcoded default. computeForNamespace is the one that matters: it builds the namespace-filtered enforcer, which is what ThingEnforcerActor enforces against on the things-service hot path - so the documented escape hatch did not hold for the very instance an operator would be trying to disable it on. The neighbouring comment already reasoned that this child must inherit the parent's read-classification bound; the memo bound was simply missed. Retain the configured bound on PolicyEnforcer (null = none configured, transient of/embed instances, library default applies) and pass it to the filtered child, alongside readClassificationCacheMaxSize. Also clamp instead of casting: a configured value above Integer.MAX_VALUE wrapped negative and silently disabled the memo - the opposite of the request. Tests: three regression tests covering child inheritance of a configured bound, of 0, and saturation above Integer.MAX_VALUE (all three fail without the fix), plus a transient-instance case; and a model-side test pinning the documented order-dependence of the new key. policies/model 1058 and policies/enforcement 272 tests green, japicmp and license:check clean, things/service still compiles. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Jäckle <thomas.jaeckle@beyonnex.io>
|
Both points confirmed and fixed in e083c61, with a JMH benchmark in 57e2aa0. 1. Eager work on the hit path. The memo now keys on the context's already-memoized ID list, and the resource pointer + subject-ID Two additions to your patch: 2. Same JMH harness run back-to-back against both enforcer versions (100 labels, 1 caller subject,
Your B/op figures reproduced exactly. Miss paths are unchanged within noise, with a small allocation drop there too (3424 → 3280 B) since One thing worth flagging:
|
Every authorization check (hasUnrestrictedPermissions, hasPartialPermissions, getSubjectsWithPermission, getSubjectsWithPartialPermission) walked the whole policy tree via visitTree on each call. Production JFR showed this tree walk as a dominant things-service CPU hotspot (~10-12% of things CPU, ~5.6% of the Ditto-microservice fleet), and the (resource, subjects, permissions) tuples recur heavily across signals. Add per-instance ConcurrentHashMap memos so a recurring verdict becomes a hash lookup instead of a full traversal. TreeBasedPolicyEnforcer is effectively immutable after createInstance (the tree is built once and only read afterwards) and a policy change produces a brand-new instance via PolicyEnforcers.defaultEvaluator, so - exactly like the classifySubjects(resource, READ) memo on PolicyEnforcer - the cache lifetime equals the instance lifetime and needs no explicit invalidation. Placing the memo on the enforcer itself (rather than the PolicyEnforcer wrapper) transparently benefits every caller across things, policies and gateway, all of which invoke these checks on the bare Enforcer. Details: - Boolean checks share one map keyed by (ResourceKey, subject-id Set, Permissions, partial-flag); the partial flag keeps unrestricted and partial checks distinct. - Subject-collection results are keyed by (ResourceKey, Permissions). The immutable EffectedSubjects instance is shared directly; getSubjectsWithPartialPermission caches an immutable snapshot and returns a fresh mutable copy per call to preserve its original contract. - The authorization-subject-id set is computed once and reused as the memo key (avoids the previous per-call HashSet rebuild on cache hits). - Each memo is best-effort bounded (MAX_MEMO_SIZE) to stay memory-safe under pathological key diversity; above the cap results are computed uncached. - Only private members and private nested value classes are added: no public API change (japicmp passes), verdicts are byte-for-byte identical to the non-memoized path. Java-8 compatible (plain value classes, no records / Set.copyOf). Adds TreeBasedPolicyEnforcerMemoTest proving repeat calls match a cold enforcer and that distinct subjects / permissions / resources / check-kinds are never conflated. Full policies/model suite (1055 tests) green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Jäckle <thomas.jaeckle@beyonnex.io>
…les it The authorization-verdict memos added for the policy-tree-walk hotspot were hard-bounded at a fixed 10000 entries and always allocated. Make the bound operator-configurable and add a true off switch: with maxMemoSize <= 0 the three ConcurrentHashMaps are not allocated at all and every check takes the direct visitTree path (behaviourally identical to a non-memoizing enforcer, zero extra memory), mirroring the existing read-classification-cache-max-size knob. - TreeBasedPolicyEnforcer: DEFAULT_MAX_MEMO_SIZE + per-instance maxMemoSize / memoizationEnabled; nullable memos allocated only when enabled; each memoized method guards on memoizationEnabled; new createInstance(entries, maxMemoSize) overload (no-arg factory delegates with the default). Package-private isMemoizationEnabled() test hook. - PolicyEnforcers: additive defaultEvaluator/memoryOptimizedEvaluator(entries, maxMemoSize) overloads (the bound only applies to the memory-optimized/tree evaluator; the trie evaluator does not memoize). - PolicyEnforcer / PolicyEnforcerCacheLoader: thread authorizationMemoMaxSize from config (new reference.conf key authorization-memo-cache-max-size, env DITTO_POLICIES_ENFORCER_AUTHORIZATION_MEMO_MAX_SIZE, default 10000) into the bare-enforcer construction, mirroring readClassificationCacheMaxSize. - Helm: expose authorizationMemoMaxSize for things/policies/connectivity (values.yaml + deployment templates); bump chart to 4.6.1. - Tests: memoization-disabled (no maps allocated, verdicts still correct) and small-cap-exceeded (correct above the cap) cases. Additive only; japicmp and the full policies/model + policies/enforcement suites stay green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Jäckle <thomas.jaeckle@beyonnex.io>
…ound Review feedback on PR eclipse-ditto#2514 (thanks @hu-ahmed) raised two issues, both confirmed. 1. A memo hit still did most of the work the memo was meant to skip. All four memoized methods built the absolute resource pointer *before* consulting the memo, and the two permission checks additionally re-derived the subject-id set by streaming the AuthorizationContext. The pointer is not part of either key and is unused on a hit, and AuthorizationContext.getAuthorizationSubjectIds() already memoizes the ids (ImmutableAuthorizationContext), so the stream-and-collect was redundant. Key the permission-check memo on the context's already-memoized id List and build both the resource pointer and the subject-id HashSet inside the memo supplier, so they are paid only on a miss. The Set is still handed to the visitor on the miss path: CheckPermissionsVisitor probes it with contains() once per subject node, so passing the List would turn the walk into O(policy subjects x caller subjects). getSubjectsWith[Partial]Permission likewise build their pointer inside the supplier. Measured memo-hit cost (ThreadMXBean.getCurrentThreadAllocatedBytes, 200k iterations after 50k warmup): before after 10-label policy, 1 caller subject 496 ns / 1366 B 52 ns / 88 B 100-label policy, 1 caller subject 460 ns / 1396 B 64 ns / 121 B 100-label policy, 5 caller subjects 404 ns / 1528 B 72 ns / 152 B Consequence, now documented on PermissionCheckKey: list equality makes the key order-dependent, so two contexts holding the same subjects in a different order occupy separate entries. Verdicts are unaffected (each entry is computed from its own ids) and the order is stable per caller. The javadoc claim of order-independence and the comment calling the eager work "cheap" are both gone - that work was ~1.1 KB of the ~1.4 KB allocated per hit. 2. authorization-memo-cache-max-size = 0 did not fully disable the memo. The configured value only reached the enforcer built by withResolvedImportsAndNamespacePolicies; the other defaultEvaluator call sites used the hardcoded default. computeForNamespace is the one that matters: it builds the namespace-filtered enforcer, which is what ThingEnforcerActor enforces against on the things-service hot path - so the documented escape hatch did not hold for the very instance an operator would be trying to disable it on. The neighbouring comment already reasoned that this child must inherit the parent's read-classification bound; the memo bound was simply missed. Retain the configured bound on PolicyEnforcer (null = none configured, transient of/embed instances, library default applies) and pass it to the filtered child, alongside readClassificationCacheMaxSize. Also clamp instead of casting: a configured value above Integer.MAX_VALUE wrapped negative and silently disabled the memo - the opposite of the request. Tests: three regression tests covering child inheritance of a configured bound, of 0, and saturation above Integer.MAX_VALUE (all three fail without the fix), plus a transient-instance case; and a model-side test pinning the documented order-dependence of the new key. policies/model 1058 and policies/enforcement 272 tests green, japicmp and license:check clean, things/service still compiles. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Jäckle <thomas.jaeckle@beyonnex.io>
The review numbers were produced with a throwaway ThreadMXBean harness, so they were not reproducible. Add a JMH benchmark alongside the existing ClassifyReadSubjectsBenchmark (things/service already wires the jmh-generator-annprocess annotation processor, so no pom change is needed). Benchmarks, parameterised over policy size (10/100 labels) and caller subject count (1/5): - hasUnrestrictedPermissionsMemoHit / getSubjectsWithPermissionMemoHit: warm enforcer, every call a memo hit - the fast path. - hasUnrestrictedPermissionsNoMemo / getSubjectsWithPermissionNoMemo: enforcer built with maxMemoSize = 0, so every call walks the whole tree - the baseline the memo removes. - hasUnrestrictedPermissionsMemoMiss vs hasUnrestrictedPermissionsNoMemoSameKeys: tiny cap plus a 64-key resource pool so (almost) every call misses. Both cycle the same pool, so the pair is directly comparable and shows the memo adds no overhead on the pathological no-repeat workload. - formerlyEagerPerHitWork: the work a hit used to pay before consulting the map (absolute resource pointer + streaming the AuthorizationContext into a subject-id Set). Neither is part of the memo key nor used on a hit, so both now live inside the memo supplier. Keeping it measurable guards against reintroducing eager work on the hit path. Measured (100 labels, 1 caller subject, -prof gc): hasUnrestrictedPermissionsMemoHit 63.7 ns / 120 B hasUnrestrictedPermissionsNoMemo 4928 ns / 3280 B (~77x, 27x) hasUnrestrictedPermissionsMemoMiss 4821 ns / 3428 B hasUnrestrictedPermissionsNoMemoSameKeys 4846 ns / 3528 B (no regression) getSubjectsWithPermissionMemoHit 51.6 ns / 104 B getSubjectsWithPermissionNoMemo 44123 ns / 153280 B (~855x, 1474x) formerlyEagerPerHitWork 147 ns / 1096 B formerlyEagerPerHitWork independently reproduces the review's finding: the eager work allocated ~1.1 KB, roughly nine times the 120 B of the whole memo hit that replaced it. main() now honours command-line arguments (via CommandLineOptions as the options parent) so "-prof gc" and "-p" actually take effect; the javadoc documents the exec:exec invocation, since exec:java runs in-process and JMH's forked VM then fails with ClassNotFoundException: ForkedMain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Jäckle <thomas.jaeckle@beyonnex.io>
master's 4f3bacd already bumped the chart to 4.6.1 for its own change (operator-metrics MongoDB read settings). This branch had independently bumped 4.6.0 -> 4.6.1, so rebasing onto master left the version untouched — git saw the bump as already applied — and this branch's chart changes (the three deployment templates plus values.yaml) would have shipped under a version number master had already claimed, which is what the CI Helm lint gate is there to catch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Jäckle <thomas.jaeckle@beyonnex.io>
57e2aa0 to
b6b30df
Compare
hu-ahmed
left a comment
There was a problem hiding this comment.
LGTM!
Thanks for the improvements
Release notes for the 3.9.7 security bugfix release, covering the two security advisories, the performance work of the milestone and the two bugfixes. Both security fixes were merged from private forks, so there are no public pull requests to link. The release notes reference the GitHub Security Advisories, the reserved CVEs and the fix commits instead: - CVE-2026-82958 / GHSA-cgfq-3fv9-5c44 (high, fixed by 73bf966) - JSON template injection in the connectivity ImplicitThingCreationMessageMapper - CVE-2026-84175 / GHSA-7f3j-xpvm-wwmg (medium, fixed by a0e704c) - SSRF via WoT ThingModel definition URL in the things service Also bumps the Helm chart to 4.7.0 / appVersion 3.9.7 and records the chart changes in its CHANGELOG: the new WoT hostValidation values of the SSRF fix, authorizationMemoMaxSize (#2514), the operator-metrics persistence blocks (#2510), the nginx/swagger-ui image bumps, and the Swagger UI OIDC mount fix (#2478 / #2533) moved out of [Unreleased]. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ound Review feedback on PR #2514 (thanks @hu-ahmed) raised two issues, both confirmed. 1. A memo hit still did most of the work the memo was meant to skip. All four memoized methods built the absolute resource pointer *before* consulting the memo, and the two permission checks additionally re-derived the subject-id set by streaming the AuthorizationContext. The pointer is not part of either key and is unused on a hit, and AuthorizationContext.getAuthorizationSubjectIds() already memoizes the ids (ImmutableAuthorizationContext), so the stream-and-collect was redundant. Key the permission-check memo on the context's already-memoized id List and build both the resource pointer and the subject-id HashSet inside the memo supplier, so they are paid only on a miss. The Set is still handed to the visitor on the miss path: CheckPermissionsVisitor probes it with contains() once per subject node, so passing the List would turn the walk into O(policy subjects x caller subjects). getSubjectsWith[Partial]Permission likewise build their pointer inside the supplier. Measured memo-hit cost (ThreadMXBean.getCurrentThreadAllocatedBytes, 200k iterations after 50k warmup): before after 10-label policy, 1 caller subject 496 ns / 1366 B 52 ns / 88 B 100-label policy, 1 caller subject 460 ns / 1396 B 64 ns / 121 B 100-label policy, 5 caller subjects 404 ns / 1528 B 72 ns / 152 B Consequence, now documented on PermissionCheckKey: list equality makes the key order-dependent, so two contexts holding the same subjects in a different order occupy separate entries. Verdicts are unaffected (each entry is computed from its own ids) and the order is stable per caller. The javadoc claim of order-independence and the comment calling the eager work "cheap" are both gone - that work was ~1.1 KB of the ~1.4 KB allocated per hit. 2. authorization-memo-cache-max-size = 0 did not fully disable the memo. The configured value only reached the enforcer built by withResolvedImportsAndNamespacePolicies; the other defaultEvaluator call sites used the hardcoded default. computeForNamespace is the one that matters: it builds the namespace-filtered enforcer, which is what ThingEnforcerActor enforces against on the things-service hot path - so the documented escape hatch did not hold for the very instance an operator would be trying to disable it on. The neighbouring comment already reasoned that this child must inherit the parent's read-classification bound; the memo bound was simply missed. Retain the configured bound on PolicyEnforcer (null = none configured, transient of/embed instances, library default applies) and pass it to the filtered child, alongside readClassificationCacheMaxSize. Also clamp instead of casting: a configured value above Integer.MAX_VALUE wrapped negative and silently disabled the memo - the opposite of the request. Tests: three regression tests covering child inheritance of a configured bound, of 0, and saturation above Integer.MAX_VALUE (all three fail without the fix), plus a transient-instance case; and a model-side test pinning the documented order-dependence of the new key. policies/model 1058 and policies/enforcement 272 tests green, japicmp and license:check clean, things/service still compiles. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Jäckle <thomas.jaeckle@beyonnex.io>
Release notes for the 3.9.7 security bugfix release, covering the two security advisories, the performance work of the milestone and the two bugfixes. Both security fixes were merged from private forks, so there are no public pull requests to link. The release notes reference the GitHub Security Advisories, the reserved CVEs and the fix commits instead: - CVE-2026-82958 / GHSA-cgfq-3fv9-5c44 (high, fixed by 73bf966) - JSON template injection in the connectivity ImplicitThingCreationMessageMapper - CVE-2026-84175 / GHSA-7f3j-xpvm-wwmg (medium, fixed by a0e704c) - SSRF via WoT ThingModel definition URL in the things service Also bumps the Helm chart to 4.7.0 / appVersion 3.9.7 and records the chart changes in its CHANGELOG: the new WoT hostValidation values of the SSRF fix, authorizationMemoMaxSize (#2514), the operator-metrics persistence blocks (#2510), the nginx/swagger-ui image bumps, and the Swagger UI OIDC mount fix (#2478 / #2533) moved out of [Unreleased]. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The release notes named the HOCON key of the #2514 authorization-verdict memo `authorization-memo-max-size`, but the actual key is `authorization-memo-cache-max-size` (policies/enforcement reference.conf, read via AUTHORIZATION_MEMO_MAX_SIZE_KEY in PolicyEnforcerCacheLoader). An operator following the notes to set it via HOCON would have silently configured nothing. Also corrects the surrounding path: the setting lives directly under the `ditto.policies-enforcer-cache` block, not under a `cache` sub-block of a policy-enforcer config. The stated default (10000), env variable (DITTO_POLICIES_ENFORCER_AUTHORIZATION_MEMO_MAX_SIZE) and Helm value name (authorizationMemoMaxSize) were already correct and are unchanged. The same error is present on master and should be corrected there as well. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Jäckle <thomas.jaeckle@beyonnex.io>
The release notes named the HOCON key of the #2514 authorization-verdict memo `authorization-memo-max-size`, but the actual key is `authorization-memo-cache-max-size` (policies/enforcement reference.conf, read via AUTHORIZATION_MEMO_MAX_SIZE_KEY in PolicyEnforcerCacheLoader). An operator following the notes to set it via HOCON would have silently configured nothing. Also corrects the surrounding path: the setting lives directly under the `ditto.policies-enforcer-cache` block, not under a `cache` sub-block of a policy-enforcer config. The stated default (10000), env variable (DITTO_POLICIES_ENFORCER_AUTHORIZATION_MEMO_MAX_SIZE) and Helm value name (authorizationMemoMaxSize) were already correct and are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Jäckle <thomas.jaeckle@beyonnex.io> (cherry picked from commit 0d31478)
What
Memoizes the per-command policy-tree walk in
TreeBasedPolicyEnforcerso recurring authorization decisions become a hash lookup instead of a full tree traversal.Why
A production JFR profile showed the policy-tree walk (
visitTree) as a dominant things-service CPU hotspot — roughly 10–12% of things-service CPU and ~5.6% of overall Ditto-microservice fleet CPU. Every authorization check (hasUnrestrictedPermissions,hasPartialPermissions,getSubjectsWithPermission,getSubjectsWithPartialPermission) walks the entire policy tree on each call, and the(resource, subjects, permissions)tuples recur heavily across signals (the same subjects repeatedly access the same resources).This mirrors the existing
classifySubjects(resource, READ)memoization onPolicyEnforcer:TreeBasedPolicyEnforceris effectively immutable aftercreateInstance(itstreeis built once and only read afterwards), and a policy change produces a brand-new instance viaPolicyEnforcers.defaultEvaluator. So the cache lifetime equals the instance lifetime and needs no explicit invalidation.What changed
Added per-instance
ConcurrentHashMapmemos insideTreeBasedPolicyEnforcer:(ResourceKey, subject-id Set, Permissions, partial-flag)— the partial flag keeps unrestricted and partial checks distinct.(ResourceKey, Permissions). The immutableEffectedSubjectsinstance is shared directly;getSubjectsWithPartialPermissioncaches an immutable snapshot and returns a fresh mutable copy per call to preserve its original contract.Setis computed once and reused as the memo key, avoiding the previous per-callHashSetrebuild on cache hits.MAX_MEMO_SIZE) to stay memory-safe under pathological key diversity; above the cap, results are computed uncached.Placing the memo on the enforcer itself (rather than the
PolicyEnforcerwrapper) transparently benefits every caller across things, policies and gateway, all of which invoke these checks on the bareEnforcer.Behavior & compatibility
ConcurrentHashMapwith a get→compute→putIfAbsenthelper; the computation only reads the immutable tree and never re-enters the map.Set.copyOf).TreeBasedPolicyEnforcerMemoTestproving repeat calls match a cold enforcer and that distinct subjects / permissions / resources / check-kinds are never conflated. The fullpolicies/modelsuite (1055 tests) passes.