Skip to content

Commit 86eb6d0

Browse files
authored
Merge pull request #2484 from beyonnex-io/feature/cache-read-subject-classification
Memoize per-resource READ subject classification in PolicyEnforcer (per-event enforcement CPU hotspot)
2 parents f07c50c + ced7ddc commit 86eb6d0

16 files changed

Lines changed: 421 additions & 33 deletions

File tree

deployment/helm/ditto/Chart.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ description: |
1616
A digital twin is a virtual, cloud based, representation of his real world counterpart
1717
(real world “Things”, e.g. devices like sensors, smart heating, connected cars, smart grids, EV charging stations etc).
1818
type: application
19-
version: 4.3.0 # chart version is effectively set by release-job
19+
version: 4.3.1 # chart version is effectively set by release-job
2020
appVersion: 3.9.3
2121
keywords:
2222
- iot-chart

deployment/helm/ditto/templates/connectivity-deployment.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,8 @@ spec:
312312
value: "{{ .Values.connectivity.config.policiesEnforcer.cache.maxSize }}"
313313
- name: DITTO_POLICIES_ENFORCER_NAMESPACE_FILTERED_MAX_SIZE
314314
value: "{{ .Values.connectivity.config.policiesEnforcer.cache.namespaceFilteredMaxSize }}"
315+
- name: DITTO_POLICIES_ENFORCER_READ_CLASSIFICATION_MAX_SIZE
316+
value: "{{ .Values.connectivity.config.policiesEnforcer.cache.readClassificationMaxSize }}"
315317
- name: DITTO_POLICIES_ENFORCER_CACHE_EXPIRE_AFTER_WRITE
316318
value: "{{ .Values.connectivity.config.policiesEnforcer.cache.expireAfterWrite }}"
317319
- name: DITTO_POLICIES_ENFORCER_CACHE_EXPIRE_AFTER_ACCESS

deployment/helm/ditto/templates/policies-deployment.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,8 @@ spec:
316316
value: "{{ .Values.policies.config.policiesEnforcer.cache.maxSize }}"
317317
- name: DITTO_POLICIES_ENFORCER_NAMESPACE_FILTERED_MAX_SIZE
318318
value: "{{ .Values.policies.config.policiesEnforcer.cache.namespaceFilteredMaxSize }}"
319+
- name: DITTO_POLICIES_ENFORCER_READ_CLASSIFICATION_MAX_SIZE
320+
value: "{{ .Values.policies.config.policiesEnforcer.cache.readClassificationMaxSize }}"
319321
- name: DITTO_POLICIES_ENFORCER_CACHE_EXPIRE_AFTER_WRITE
320322
value: "{{ .Values.policies.config.policiesEnforcer.cache.expireAfterWrite }}"
321323
- name: DITTO_POLICIES_ENFORCER_CACHE_EXPIRE_AFTER_ACCESS

deployment/helm/ditto/templates/things-deployment.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,8 @@ spec:
314314
value: "{{ .Values.things.config.policiesEnforcer.cache.maxSize }}"
315315
- name: DITTO_POLICIES_ENFORCER_NAMESPACE_FILTERED_MAX_SIZE
316316
value: "{{ .Values.things.config.policiesEnforcer.cache.namespaceFilteredMaxSize }}"
317+
- name: DITTO_POLICIES_ENFORCER_READ_CLASSIFICATION_MAX_SIZE
318+
value: "{{ .Values.things.config.policiesEnforcer.cache.readClassificationMaxSize }}"
317319
- name: DITTO_POLICIES_ENFORCER_CACHE_EXPIRE_AFTER_WRITE
318320
value: "{{ .Values.things.config.policiesEnforcer.cache.expireAfterWrite }}"
319321
- name: DITTO_POLICIES_ENFORCER_CACHE_EXPIRE_AFTER_ACCESS

deployment/helm/ditto/values.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,10 @@ policies:
847847
# namespaceFilteredMaxSize bounds the per-enforcer cache of namespace-filtered enforcers, avoiding
848848
# rebuilding the enforcer tree per signal for policies with namespace-scoped entries
849849
namespaceFilteredMaxSize: 100
850+
# readClassificationMaxSize bounds the per-enforcer memo of classifySubjects(resource, READ) results,
851+
# avoiding re-walking the policy tree per emitted ThingEvent for per-event read-authorization and
852+
# read-grant collection; caps distinct resource paths cached per policy
853+
readClassificationMaxSize: 1000
850854
# expireAfterWrite the maximum duration of inconsistency after losing a cache invalidation
851855
expireAfterWrite: 4h
852856
# expireAfterAccess prolonged on each cache access by that duration
@@ -1218,6 +1222,10 @@ things:
12181222
# namespaceFilteredMaxSize bounds the per-enforcer cache of namespace-filtered enforcers, avoiding
12191223
# rebuilding the enforcer tree per signal for policies with namespace-scoped entries
12201224
namespaceFilteredMaxSize: 100
1225+
# readClassificationMaxSize bounds the per-enforcer memo of classifySubjects(resource, READ) results,
1226+
# avoiding re-walking the policy tree per emitted ThingEvent for per-event read-authorization and
1227+
# read-grant collection; caps distinct resource paths cached per policy
1228+
readClassificationMaxSize: 1000
12211229
# expireAfterWrite the maximum duration of inconsistency after losing a cache invalidation
12221230
expireAfterWrite: 4h
12231231
# expireAfterAccess prolonged on each cache access by that duration
@@ -1929,6 +1937,10 @@ connectivity:
19291937
# namespaceFilteredMaxSize bounds the per-enforcer cache of namespace-filtered enforcers, avoiding
19301938
# rebuilding the enforcer tree per signal for policies with namespace-scoped entries
19311939
namespaceFilteredMaxSize: 100
1940+
# readClassificationMaxSize bounds the per-enforcer memo of classifySubjects(resource, READ) results,
1941+
# avoiding re-walking the policy tree per emitted ThingEvent for per-event read-authorization and
1942+
# read-grant collection; caps distinct resource paths cached per policy
1943+
readClassificationMaxSize: 1000
19321944
# expireAfterWrite the maximum duration of inconsistency after losing a cache invalidation
19331945
expireAfterWrite: 4h
19341946
# expireAfterAccess prolonged on each cache access by that duration
Binary file not shown.

policies/enforcement/src/main/java/org/eclipse/ditto/policies/enforcement/PolicyEnforcerCacheLoader.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,15 @@ public final class PolicyEnforcerCacheLoader implements AsyncCacheLoader<PolicyI
4040
* namespace-filtered-enforcer cache size; default provided by reference.conf. */
4141
private static final String NAMESPACE_FILTERED_ENFORCER_MAX_SIZE_KEY = "namespace-filtered-enforcer-max-size";
4242

43+
/** Config key (relative to {@link PolicyEnforcerProvider#ENFORCER_CACHE_CONFIG_KEY}) for the per-enforcer
44+
* read-classification cache size; default provided by reference.conf. */
45+
private static final String READ_CLASSIFICATION_MAX_SIZE_KEY = "read-classification-cache-max-size";
46+
4347
private final PolicyCacheLoader delegate;
4448
private final Executor enforcementCacheExecutor;
4549
private final NamespacePoliciesConfig namespacePoliciesConfig;
4650
private final long namespaceFilteredEnforcerCacheMaxSize;
51+
private final long readClassificationCacheMaxSize;
4752
@Nullable
4853
private final CompletableFuture<Cache<PolicyId, Entry<PolicyEnforcer>>> cacheFuture;
4954

@@ -83,6 +88,9 @@ public PolicyEnforcerCacheLoader(final PolicyCacheLoader policyCacheLoader, fina
8388
this.namespaceFilteredEnforcerCacheMaxSize = actorSystem.settings().config()
8489
.getLong(PolicyEnforcerProvider.ENFORCER_CACHE_CONFIG_KEY + "." +
8590
NAMESPACE_FILTERED_ENFORCER_MAX_SIZE_KEY);
91+
this.readClassificationCacheMaxSize = actorSystem.settings().config()
92+
.getLong(PolicyEnforcerProvider.ENFORCER_CACHE_CONFIG_KEY + "." +
93+
READ_CLASSIFICATION_MAX_SIZE_KEY);
8694
this.cacheFuture = cacheFuture;
8795
}
8896

@@ -114,7 +122,8 @@ private CompletionStage<Entry<PolicyEnforcer>> evaluatePolicy(final Entry<Policy
114122
final var revision = entry.getRevision();
115123
final var policy = entry.getValueOrThrow();
116124
return PolicyEnforcer.withResolvedImportsAndNamespacePolicies(policy, policyResolver,
117-
namespacePoliciesConfig, namespaceFilteredEnforcerCacheMaxSize)
125+
namespacePoliciesConfig, namespaceFilteredEnforcerCacheMaxSize,
126+
readClassificationCacheMaxSize)
118127
.thenApply(enforcer -> Entry.of(revision, enforcer));
119128
} else {
120129
return CompletableFuture.completedFuture(Entry.nonexistent());

policies/enforcement/src/main/resources/reference.conf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ ditto.policies-enforcer-cache {
5353
namespace-filtered-enforcer-max-size = 100
5454
namespace-filtered-enforcer-max-size = ${?DITTO_POLICIES_ENFORCER_NAMESPACE_FILTERED_MAX_SIZE}
5555

56+
# per-enforcer memo of classifySubjects(resource, READ) results: reused by the things-service per-event
57+
# read-authorization and read-grant collection instead of re-walking the policy tree per emitted ThingEvent;
58+
# bounds the number of distinct resource paths cached per policy
59+
read-classification-cache-max-size = 1000
60+
read-classification-cache-max-size = ${?DITTO_POLICIES_ENFORCER_READ_CLASSIFICATION_MAX_SIZE}
61+
5662
# maximum duration of inconsistency after losing a cache invalidation
5763
expire-after-write = 1h
5864
expire-after-write = ${?DITTO_POLICIES_ENFORCER_CACHE_EXPIRE_AFTER_WRITE}

policies/enforcement/src/test/java/org/eclipse/ditto/policies/enforcement/PolicyEnforcerTest.java

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@
2929
import org.eclipse.ditto.policies.model.PolicyEntry;
3030
import org.eclipse.ditto.policies.model.PolicyId;
3131
import org.eclipse.ditto.policies.model.PoliciesResourceType;
32+
import org.eclipse.ditto.policies.model.ResourceKey;
3233
import org.eclipse.ditto.policies.model.Subject;
3334
import org.eclipse.ditto.policies.model.SubjectId;
35+
import org.eclipse.ditto.policies.model.enforcers.SubjectClassification;
3436
import org.junit.Test;
3537

3638
public final class PolicyEnforcerTest {
@@ -131,6 +133,67 @@ public void forNamespaceWithNullPolicyReturnsSameInstance() {
131133
assertThat(enforcer.forNamespace("any.ns")).isSameAs(enforcer);
132134
}
133135

136+
@Test
137+
public void classifyReadSubjectsMatchesDirectEnforcerClassification() {
138+
final Policy policy = PoliciesModelFactory.newPolicyBuilder(PolicyId.of("test:policy"))
139+
.set(newScopedEntry("global", "google:global-user", Collections.emptyList()))
140+
.build();
141+
final PolicyEnforcer policyEnforcer = PolicyEnforcer.of(policy);
142+
final ResourceKey root = PoliciesResourceType.thingResource("/");
143+
144+
final SubjectClassification memoized = policyEnforcer.classifyReadSubjects(root);
145+
final SubjectClassification direct = policyEnforcer.getEnforcer()
146+
.classifySubjects(root, PoliciesModelFactory.newPermissions(Permission.READ));
147+
148+
assertThat(memoized.getUnrestricted()).isEqualTo(direct.getUnrestricted());
149+
assertThat(memoized.getPartialOnly()).isEqualTo(direct.getPartialOnly());
150+
assertThat(memoized.getEffectedGranted()).isEqualTo(direct.getEffectedGranted());
151+
}
152+
153+
@Test
154+
public void classifyReadSubjectsMemoizesRepeatedCallsForSameResource() {
155+
final Policy policy = PoliciesModelFactory.newPolicyBuilder(PolicyId.of("test:policy"))
156+
.set(newScopedEntry("global", "google:global-user", Collections.emptyList()))
157+
.build();
158+
final PolicyEnforcer policyEnforcer = PolicyEnforcer.of(policy);
159+
final ResourceKey root = PoliciesResourceType.thingResource("/");
160+
161+
// A second call for the same resource must return the memoized instance, not re-walk the policy tree.
162+
final SubjectClassification first = policyEnforcer.classifyReadSubjects(root);
163+
final SubjectClassification second = policyEnforcer.classifyReadSubjects(root);
164+
165+
assertThat(second).isSameAs(first);
166+
}
167+
168+
@Test
169+
public void getRootResourceReadClassificationDelegatesToClassifyReadSubjects() {
170+
final Policy policy = PoliciesModelFactory.newPolicyBuilder(PolicyId.of("test:policy"))
171+
.set(newScopedEntry("global", "google:global-user", Collections.emptyList()))
172+
.build();
173+
final PolicyEnforcer policyEnforcer = PolicyEnforcer.of(policy);
174+
175+
assertThat(policyEnforcer.getRootResourceReadClassification())
176+
.isSameAs(policyEnforcer.classifyReadSubjects(PoliciesResourceType.thingResource("/")));
177+
}
178+
179+
@Test
180+
public void classifyReadSubjectsStaysCorrectUnderBoundedCache() {
181+
final Policy policy = PoliciesModelFactory.newPolicyBuilder(PolicyId.of("test:policy"))
182+
.set(newScopedEntry("global", "google:global-user", Collections.emptyList()))
183+
.build();
184+
// Tiny bound: cache correctness must not depend on capacity — a miss simply recomputes an equal result.
185+
final PolicyEnforcer bounded = PolicyEnforcer.of(policy, 100, 1);
186+
final ResourceKey a = PoliciesResourceType.thingResource("/features/a");
187+
final ResourceKey b = PoliciesResourceType.thingResource("/features/b");
188+
189+
final SubjectClassification classificationA = bounded.classifyReadSubjects(a);
190+
bounded.classifyReadSubjects(b);
191+
final SubjectClassification classificationAgain = bounded.classifyReadSubjects(a);
192+
193+
assertThat(classificationAgain.getUnrestricted()).isEqualTo(classificationA.getUnrestricted());
194+
assertThat(classificationAgain.getPartialOnly()).isEqualTo(classificationA.getPartialOnly());
195+
}
196+
134197
private static PolicyEntry newScopedEntry(final String label, final String subjectId, final List<String> namespaces) {
135198
return PoliciesModelFactory.newPolicyEntry(label,
136199
Collections.singletonList(Subject.newInstance(SubjectId.newInstance(subjectId))),

things/service/src/main/java/org/eclipse/ditto/things/service/enforcement/LiveSignalEnforcement.java

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,10 @@ private Signal<?> enforceLiveSignal(final StreamingType streamingType, final Sig
167167
case MESSAGES:
168168
return enforceMessageCommand((MessageCommand<?, ?>) liveSignal, enforcer.getEnforcer());
169169
case LIVE_EVENTS:
170-
return enforceLiveEvent(liveSignal, enforcer.getEnforcer());
170+
return enforceLiveEvent(liveSignal, enforcer);
171171
case LIVE_COMMANDS:
172172
ThingCommandEnforcement.authorizeByPolicyOrThrow(
173-
enforcer.getEnforcer(),
173+
enforcer,
174174
(ThingCommand<?>) liveSignal,
175175
partialAccessEventsEnabled);
176176
final var withReadSubjects = addEffectedReadSubjectsToThingLiveSignal((ThingCommand<?>) liveSignal,
@@ -212,13 +212,14 @@ <T extends Signal<T>> T addEffectedReadSubjectsToThingLiveSignal(final Signal<T>
212212
return signal.setDittoHeaders(newHeaders);
213213
}
214214

215-
private Signal<?> enforceLiveEvent(final Signal<?> liveSignal, final Enforcer enforcer) {
216-
if (enforcer.hasUnrestrictedPermissions(PoliciesResourceType.thingResource(liveSignal.getResourcePath()),
217-
liveSignal.getDittoHeaders().getAuthorizationContext(), WRITE)) {
215+
private Signal<?> enforceLiveEvent(final Signal<?> liveSignal, final PolicyEnforcer policyEnforcer) {
216+
if (policyEnforcer.getEnforcer()
217+
.hasUnrestrictedPermissions(PoliciesResourceType.thingResource(liveSignal.getResourcePath()),
218+
liveSignal.getDittoHeaders().getAuthorizationContext(), WRITE)) {
218219
LOGGER.withCorrelationId(liveSignal)
219220
.info("Live Event was authorized: <{}>", liveSignal);
220221
return ThingCommandEnforcement.addEffectedReadSubjectsToThingSignal((ThingEvent<?>) liveSignal,
221-
enforcer,
222+
policyEnforcer,
222223
partialAccessEventsEnabled);
223224
} else {
224225
LOGGER.withCorrelationId(liveSignal)

0 commit comments

Comments
 (0)