Skip to content

Memoize per-resource READ subject classification in PolicyEnforcer (per-event enforcement CPU hotspot)#2484

Open
thjaeckle wants to merge 1 commit into
eclipse-ditto:masterfrom
beyonnex-io:feature/cache-read-subject-classification
Open

Memoize per-resource READ subject classification in PolicyEnforcer (per-event enforcement CPU hotspot)#2484
thjaeckle wants to merge 1 commit into
eclipse-ditto:masterfrom
beyonnex-io:feature/cache-read-subject-classification

Conversation

@thjaeckle

Copy link
Copy Markdown
Member

What

Adds a per-resource memo of Enforcer.classifySubjects(resource, READ) on PolicyEnforcer, removing a full policy-tree walk that ran on every emitted ThingEvent.

Why — analysis

Profiling ditto-things on production (JFR) showed that, once the recent PolicyEnforcer.forNamespace(...) caching fix removed the per-signal enforcer tree rebuild, the per-event read-subject evaluation became the dominant things-service enforcement CPU consumer. Two hot paths re-walk the full TreeBasedPolicyEnforcer on every event:

  • ThingCommandEnforcement.addEffectedReadSubjectsToThingSignal computes the readGrantedSubjects for every authorized signal (used for pub/sub routing). With partial-access-events enabled (the default) it walks the policy tree per event via getSubjectsWithUnrestrictedPermission(eventResource) and getSubjectsWithPartialPermission(root).
  • ReadGrantCollector.collectSubjectsForPointer calls classifySubjects(...) per pointer, per event.

classifySubjects(resource, READ) is a pure function of the resolved policy grants and the resource path — it does not depend on the Thing's field values. IoT workloads are dominated by value-only telemetry updates that repeatedly touch the same resource paths, so the identical classification was recomputed on every event.

What changed

  • PolicyEnforcer: generalize the existing single-slot root-read-classification memo into a bounded per-ResourceKey cache, exposed as classifyReadSubjects(ResourceKey); getRootResourceReadClassification() now delegates to it. The memo lives on the PolicyEnforcer instance, which CachingPolicyEnforcerProvider replaces wholesale on any grant change (own policy, an imported policy, or a namespace-root cascade), so it is invalidated naturally — the same lifetime and invalidation story as the forNamespace memo. The bound is propagated to forNamespace filtered children, since that is the instance the things hot path enforces against.
  • Route addEffectedReadSubjectsToThingSignal and ReadGrantCollector.collectSubjectsForPointer through the memo. The resulting readGrantedSubjects set is identical to the previous formula, because at the root getSubjectsWithPartialPermission(root, READ) == partialOnly ∪ unrestricted (see ClassifySubjectsVisitor).
  • New config ditto.policies-enforcer-cache.read-classification-cache-max-size (default 1000), plumbed through PolicyEnforcerCacheLoader and the Helm chart (things/policies/connectivity).

Correctness

classifyReadSubjects returns the same SubjectClassification as a direct Enforcer.classifySubjects(resource, READ); a cache miss/eviction simply recomputes an equal result, never a wrong one. Behaviour preservation is covered by unit tests asserting the emitted readGrantedSubjects are identical to the pre-change formula, both with and without partial-read subjects, plus tests for the memo itself (equality with the direct call, memoization, correctness under a bounded cache).

Performance — JMH

ClassifyReadSubjectsBenchmark (added), AverageTime, µs/op:

Benchmark Score (µs/op) Note
baselineRecompute (previous behaviour) 2.045 ± 0.108 full policy-tree walk per call
memoizedHit (repeated resource, prod shape) 0.025 ± 0.002 ~80× faster
memoizedMiss (no-repeat + tiny cache) 2.604 ± 0.910 within noise of baseline — no regression
endToEndMemoized (full add-read-subjects) 1.311 ± 0.089 now dominated by header building

The repeated-resource case is the production shape (value-only telemetry re-touching the same paths); the pathological no-repeat case (64 distinct keys against a size-4 cache) shows the memo does not regress the worst case.

Memory

The memo adds a small, bounded retained set — SubjectClassification is a set of authorization-subject ids, capped at the configured bound per enforcer (and propagated to forNamespace children so it cannot grow unbounded). In exchange it removes the per-event transient allocation of the tree walk (temporary sets and visitor state), reducing young-gen allocation churn. The bound is operator-tunable.

The things-service per-event read-authorization
(ThingCommandEnforcement.addEffectedReadSubjectsToThingSignal) and read-grant
collection (ReadGrantCollector) walk the full policy tree via
Enforcer.classifySubjects on every emitted ThingEvent. After the forNamespace
fix removed the per-signal enforcer *rebuild*, this per-event *evaluation*
became the dominant things-service enforcement CPU consumer on prod JFR.

classifySubjects(resource, READ) is a pure function of the resolved policy
grants and the resource path -- independent of Thing field values -- so its
result is memoizable per PolicyEnforcer instance. The instance is replaced
wholesale by CachingPolicyEnforcerProvider on any grant change, invalidating
the memo naturally (same lifetime/invalidation as the existing forNamespace
and root-read-classification memos).

- PolicyEnforcer: generalize the single-slot root-read-classification memo into
  a bounded per-ResourceKey Caffeine cache exposed as
  classifyReadSubjects(ResourceKey); getRootResourceReadClassification()
  delegates to it. Bound configurable via
  ditto.policies-enforcer-cache.read-classification-cache-max-size (default
  1000), plumbed through PolicyEnforcerCacheLoader + helm.
- Route addEffectedReadSubjectsToThingSignal and
  ReadGrantCollector.collectSubjectsForPointer through the memo. The
  read-granted-subjects set is identical to the previous formula
  (getSubjectsWithPartialPermission(root) == partialOnly + unrestricted).

JMH (ClassifyReadSubjectsBenchmark): repeated-resource hit 2.12 -> 0.024 us/op
(~88x); no-repeat miss stays within noise of baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thjaeckle thjaeckle self-assigned this Jul 7, 2026
@thjaeckle thjaeckle added this to the 3.9.4 milestone Jul 7, 2026
@thjaeckle

Copy link
Copy Markdown
Member Author

@thjaeckle thjaeckle requested a review from hu-ahmed July 8, 2026 08:36

@hu-ahmed hu-ahmed left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants