Skip to content

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

Merged
thjaeckle merged 1 commit into
eclipse-ditto:masterfrom
beyonnex-io:feature/cache-read-subject-classification
Jul 10, 2026
Merged

Memoize per-resource READ subject classification in PolicyEnforcer (per-event enforcement CPU hotspot)#2484
thjaeckle merged 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!

@thjaeckle thjaeckle merged commit 86eb6d0 into eclipse-ditto:master Jul 10, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants