From db2a88e99d098d77fad5b0648d5ee6fa3dee1d9d Mon Sep 17 00:00:00 2001 From: Joerg Hoh Date: Sun, 28 Jun 2026 16:28:19 +0200 Subject: [PATCH 1/3] SLING-12217 Cache model implementation class lookups per ResourceResolver - ModelAdapterFactory: cache the resolved ModelClass (or a negative-result sentinel) in the backing ResourceResolver.getPropertyMap(), as a bounded LRU map to avoid unbounded growth - AdapterImplementations: add requiresImplementationPickerLookup() to limit caching to the picker-based path. - ModelAdapterFactoryConfiguration: add cache_implementation_lookups (default true) and implementation_lookup_cache_size (default 100). - add test cases and run relevant existing tests with and without this feature enabled --- pom.xml | 5 + .../models/impl/AdapterImplementations.java | 22 ++ .../models/impl/ModelAdapterFactory.java | 126 ++++++++- .../ModelAdapterFactoryConfiguration.java | 17 ++ .../sling/models/impl/AdapterFactoryTest.java | 10 + .../apache/sling/models/impl/CachingTest.java | 12 +- .../impl/ImplementationLookupCachingTest.java | 245 ++++++++++++++++++ .../models/impl/ImplementsExtendsTest.java | 12 +- ...Factory_ImplementationPickerOrderTest.java | 15 +- 9 files changed, 460 insertions(+), 4 deletions(-) create mode 100644 src/test/java/org/apache/sling/models/impl/ImplementationLookupCachingTest.java diff --git a/pom.xml b/pom.xml index bd2ed9c2..66a7c50c 100644 --- a/pom.xml +++ b/pom.xml @@ -175,6 +175,11 @@ junit-jupiter-api test + + org.junit.jupiter + junit-jupiter-params + test + org.junit.platform junit-platform-engine diff --git a/src/main/java/org/apache/sling/models/impl/AdapterImplementations.java b/src/main/java/org/apache/sling/models/impl/AdapterImplementations.java index 956cabcf..367d0e29 100644 --- a/src/main/java/org/apache/sling/models/impl/AdapterImplementations.java +++ b/src/main/java/org/apache/sling/models/impl/AdapterImplementations.java @@ -227,6 +227,25 @@ public ModelClass lookup( return null; } + /** + * Checks whether resolving the implementation for the given adapter type requires consulting the + * {@link ImplementationPicker}s. This is only the case when the adapter type is not directly registered as its own + * model (the cheap, deterministic fast path in {@link #lookup}) but has one or more implementations registered with + * a different adapter class. Only that path can cause repository access (resource-type based picking) and is + * therefore worth caching, see SLING-12217. + * + * @param adapterType the adapter type to check + * @return {@code true} if a picker has to be consulted to resolve the implementation + */ + public boolean requiresImplementationPickerLookup(Class adapterType) { + String key = adapterType.getName(); + if (modelClasses.containsKey(key)) { + return false; + } + ConcurrentNavigableMap> implementations = adapterImplementations.get(key); + return implementations != null && !implementations.isEmpty(); + } + /** * @param adapterType the type to check * @return {@code true} in case the given type is a model (may be with a different adapter class) @@ -319,6 +338,9 @@ protected static Class getModelClassForResource(final Resource resource, fina return null; } ResourceResolver resolver = resource.getResourceResolver(); + if (resolver == null) { + return null; + } final String originalResourceType = resource.getResourceType(); Class modelClass = getClassFromResourceTypeMap(originalResourceType, map, resolver); if (modelClass != null) { diff --git a/src/main/java/org/apache/sling/models/impl/ModelAdapterFactory.java b/src/main/java/org/apache/sling/models/impl/ModelAdapterFactory.java index 5b7fb8e4..6252608f 100644 --- a/src/main/java/org/apache/sling/models/impl/ModelAdapterFactory.java +++ b/src/main/java/org/apache/sling/models/impl/ModelAdapterFactory.java @@ -36,6 +36,7 @@ import java.util.HashMap; import java.util.Hashtable; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -54,6 +55,7 @@ import org.apache.sling.api.adapter.AdapterFactory; import org.apache.sling.api.adapter.AdapterManager; import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceResolver; import org.apache.sling.models.annotations.Model; import org.apache.sling.models.annotations.ValidationStrategy; import org.apache.sling.models.annotations.ViaProviderType; @@ -129,6 +131,30 @@ public class ModelAdapterFactory implements AdapterFactory, Runnable, ModelFacto private static final String REQUEST_CACHE_ATTRIBUTE = ModelAdapterFactory.class.getName() + ".AdapterCache"; + // Implementation lookup cache: + // Resolving the implementation class for an adapter type is purely a function of the requested adapter type, the + // type of the adaptable and the resource type of the (possibly wrapped) resource. Only the picker-based path walks + // the resource super type hierarchy (repository access), so caching is limited to that path (see + // AdapterImplementations#requiresImplementationPickerLookup) and is scoped per ResourceResolver via its property + // map. This removes redundant lookups for the canCreateFromAdaptable/createModel pair and across components sharing + // a resource type, while leaving the cheap fast path untouched. The cache is a bounded LRU map (see + // implementationLookupCacheSize) so it cannot grow unbounded for long-lived resolvers or large numbers of distinct + // resource types. The cache key is "adaptableClass|adapterType|resourceType"; the concrete adaptable type is + // included so custom adaptables (e.g. request wrappers) do not share entries with unrelated ones. + + /** + * Key under which the (bounded) implementation lookup cache is stored in the backing + * {@link ResourceResolver#getPropertyMap()} (see {@link #getImplementationTypeForAdapterType(Class, Object)}). + */ + private static final String IMPLEMENTATION_LOOKUP_CACHE_KEY = + ModelAdapterFactory.class.getName() + ".ImplementationLookup"; + + /** + * Sentinel stored in the implementation lookup cache to memoize a negative result (no model implementation found + * for the given adaptable and resource type), so that repeated probing does not trigger the lookup again. + */ + private static final Object NO_MODEL_IMPLEMENTATION = new Object(); + private final Logger log = LoggerFactory.getLogger(ModelAdapterFactory.class); private ReferenceQueue queue; @@ -212,6 +238,12 @@ private void clearDisposalCallbackRegistryQueue() { private Map, SoftReference>> adapterCache; + /** Whether to cache model implementation class lookups per ResourceResolver, see SLING-12217. */ + private boolean cacheImplementationLookups; + + /** Maximum number of entries in the per-ResourceResolver implementation lookup cache, see SLING-12217. */ + private int implementationLookupCacheSize; + private SlingModelsScriptEngineFactory scriptEngineFactory; @Override @@ -324,22 +356,111 @@ public boolean isModelClass(@NotNull Class type) { * href="http://sling.apache.org/documentation/bundles/models.html#specifying-an-alternate-adapter-class-since-sling-models-110">Specifying * an Alternate Adapter Class */ + @SuppressWarnings("unchecked") private ModelClass getImplementationTypeForAdapterType( Class requestedType, Object adaptable) { + // only the (repository-accessing) picker-based path is cached + final boolean cacheable = + cacheImplementationLookups && adapterImplementations.requiresImplementationPickerLookup(requestedType); + final Map cache = cacheable ? getImplementationLookupCache(adaptable) : null; + final String cacheKey = cache != null ? getImplementationLookupCacheKey(requestedType, adaptable) : null; + if (cache != null && cacheKey != null) { + final Object cached = cache.get(cacheKey); + if (cached == NO_MODEL_IMPLEMENTATION) { + throw newNoImplementationException(requestedType, adaptable); + } else if (cached != null) { + return (ModelClass) cached; + } + } + // lookup ModelClass wrapper for implementation type // additionally check if a different implementation class was registered for this adapter type // the adapter implementation is initially filled by the ModelPackageBundleList ModelClass modelClass = this.adapterImplementations.lookup(requestedType, adaptable, this.implementationPickers); + + if (cache != null && cacheKey != null) { + cache.put(cacheKey, modelClass != null ? modelClass : NO_MODEL_IMPLEMENTATION); + } + if (modelClass != null) { log.debug("Using implementation type {} for requested adapter type {}", modelClass, requestedType); return modelClass; } // throw exception here - throw new ModelClassException("Could not yet find an adapter factory for the model " + requestedType + throw newNoImplementationException(requestedType, adaptable); + } + + private ModelClassException newNoImplementationException(Class requestedType, Object adaptable) { + return new ModelClassException("Could not yet find an adapter factory for the model " + requestedType + " from adaptable " + adaptable.getClass()); } + /** + * Returns the (resolver scoped) cache for implementation lookups, lazily creating it in the + * {@link ResourceResolver#getPropertyMap() property map} of the resolver backing the given adaptable. Returns + * {@code null} if no resource (and hence no resolver) can be derived from the adaptable. + * + *

The cache is a bounded LRU map: even for long-lived (e.g. service) resolvers or pathological numbers of + * distinct resource types, it never holds more than {@link #implementationLookupCacheSize} entries, evicting the + * least recently used ones. + */ + @SuppressWarnings("unchecked") + private @Nullable Map getImplementationLookupCache(Object adaptable) { + final Resource resource = getResourceFromAdaptable(adaptable); + if (resource == null) { + return null; + } + final ResourceResolver resolver = resource.getResourceResolver(); + if (resolver == null) { + return null; + } + final Map propertyMap = resolver.getPropertyMap(); + Map cache = (Map) propertyMap.get(IMPLEMENTATION_LOOKUP_CACHE_KEY); + if (cache == null) { + cache = createBoundedLookupCache(implementationLookupCacheSize); + propertyMap.put(IMPLEMENTATION_LOOKUP_CACHE_KEY, cache); + } + return cache; + } + + private static Map createBoundedLookupCache(final int maxSize) { + return Collections.synchronizedMap(new LinkedHashMap(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxSize; + } + }); + } + + /** + * Builds the cache key for an implementation lookup, or {@code null} if no resource type is available, in which case + * the lookup must not be cached. + */ + private @Nullable String getImplementationLookupCacheKey(Class requestedType, Object adaptable) { + final Resource resource = getResourceFromAdaptable(adaptable); + if (resource == null) { + return null; + } + final String resourceType = resource.getResourceType(); + if (resourceType == null) { + return null; + } + return adaptable.getClass().getName() + '|' + requestedType.getName() + '|' + resourceType; + } + + @SuppressWarnings("deprecation") + private @Nullable Resource getResourceFromAdaptable(Object adaptable) { + if (adaptable instanceof Resource resource) { + return resource; + } else if (adaptable instanceof SlingJakartaHttpServletRequest jakartaRequest) { + return jakartaRequest.getResource(); + } else if (adaptable instanceof SlingHttpServletRequest javaxRequest) { + return javaxRequest.getResource(); + } + return null; + } + @SuppressWarnings("unchecked") private Map, SoftReference> getOrCreateCache(final Object adaptable) { Map, SoftReference> adaptableCache; @@ -1186,6 +1307,9 @@ protected ThreadInvocationCounter initialValue() { this.adapterCache = Collections.synchronizedMap(new WeakHashMap, SoftReference>>()); + this.cacheImplementationLookups = configuration.cache_implementation_lookups(); + this.implementationLookupCacheSize = Math.max(1, configuration.implementation_lookup_cache_size()); + BundleContext bundleContext = ctx.getBundleContext(); this.queue = new ReferenceQueue<>(); this.disposalCallbacks = new ConcurrentHashMap<>(); diff --git a/src/main/java/org/apache/sling/models/impl/ModelAdapterFactoryConfiguration.java b/src/main/java/org/apache/sling/models/impl/ModelAdapterFactoryConfiguration.java index 86b25e1a..e2c9f593 100644 --- a/src/main/java/org/apache/sling/models/impl/ModelAdapterFactoryConfiguration.java +++ b/src/main/java/org/apache/sling/models/impl/ModelAdapterFactoryConfiguration.java @@ -30,4 +30,21 @@ name = "Cleanup Job Period", description = "Period in seconds at which OSGi service references from ThreadLocals will be cleaned up.") long cleanup_job_period() default 30l; + + @AttributeDefinition( + name = "Cache Implementation Lookups", + description = "Cache the resolution of a model implementation class per adaptable type and resource type " + + "within a ResourceResolver. This avoids redundant repository lookups when the same model is " + + "resolved repeatedly (e.g. once for canCreateFromAdaptable and once for createModel, or across " + + "many components sharing a resource type). Disable this if a custom ImplementationPicker selects " + + "an implementation based on more than the resource type (e.g. selectors or request attributes).") + boolean cache_implementation_lookups() default true; + + @AttributeDefinition( + name = "Implementation Lookup Cache Size", + description = "Maximum number of entries in the per-ResourceResolver implementation lookup cache (see " + + "'Cache Implementation Lookups'). Once exceeded, the least recently used entries are evicted, so " + + "that the cache cannot grow unbounded for long-lived resolvers or large numbers of distinct " + + "resource types. Must be greater than 0.") + int implementation_lookup_cache_size() default 100; } diff --git a/src/test/java/org/apache/sling/models/impl/AdapterFactoryTest.java b/src/test/java/org/apache/sling/models/impl/AdapterFactoryTest.java index 003dd5f3..1da4e485 100644 --- a/src/test/java/org/apache/sling/models/impl/AdapterFactoryTest.java +++ b/src/test/java/org/apache/sling/models/impl/AdapterFactoryTest.java @@ -80,13 +80,23 @@ public static ModelAdapterFactory createModelAdapterFactory() { return createModelAdapterFactory(bundleContext); } + public static ModelAdapterFactory createModelAdapterFactory(boolean cacheImplementationLookups) { + return createModelAdapterFactory(Mockito.mock(BundleContext.class), cacheImplementationLookups); + } + public static ModelAdapterFactory createModelAdapterFactory(BundleContext bundleContext) { + return createModelAdapterFactory(bundleContext, true); + } + + public static ModelAdapterFactory createModelAdapterFactory( + BundleContext bundleContext, boolean cacheImplementationLookups) { ComponentContext componentCtx = Mockito.mock(ComponentContext.class); when(componentCtx.getBundleContext()).thenReturn(bundleContext); ModelAdapterFactory factory = new ModelAdapterFactory(); Converter c = Converters.standardConverter(); Map map = new HashMap<>(); + map.put("cache.implementation.lookups", Boolean.toString(cacheImplementationLookups)); ModelAdapterFactoryConfiguration config = c.convert(map).to(ModelAdapterFactoryConfiguration.class); factory.activate(componentCtx, config); factory.injectAnnotationProcessorFactories = Collections.emptyList(); diff --git a/src/test/java/org/apache/sling/models/impl/CachingTest.java b/src/test/java/org/apache/sling/models/impl/CachingTest.java index b9217f3e..8c46a60d 100644 --- a/src/test/java/org/apache/sling/models/impl/CachingTest.java +++ b/src/test/java/org/apache/sling/models/impl/CachingTest.java @@ -39,6 +39,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.Parameter; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; @@ -50,9 +53,16 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +// run with the implementation-lookup cache (SLING-12217) both enabled and disabled - the model instance caching +// behavior asserted here must be identical regardless of that optimization +@ParameterizedClass +@ValueSource(booleans = {true, false}) @ExtendWith(MockitoExtension.class) class CachingTest { + @Parameter + private boolean cacheImplementationLookups; + @Spy private MockSlingJakartaHttpServletRequest request = new MockSlingJakartaHttpServletRequest(null); @@ -65,7 +75,7 @@ class CachingTest { @BeforeEach void setup() { - factory = AdapterFactoryTest.createModelAdapterFactory(); + factory = AdapterFactoryTest.createModelAdapterFactory(cacheImplementationLookups); factory.injectors = Arrays.asList(new RequestAttributeInjector(), new ValueMapInjector()); factory.adapterImplementations.addClassesAsAdapterAndImplementation( CachedModel.class, diff --git a/src/test/java/org/apache/sling/models/impl/ImplementationLookupCachingTest.java b/src/test/java/org/apache/sling/models/impl/ImplementationLookupCachingTest.java new file mode 100644 index 00000000..519c2155 --- /dev/null +++ b/src/test/java/org/apache/sling/models/impl/ImplementationLookupCachingTest.java @@ -0,0 +1,245 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.sling.models.impl; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.models.annotations.Model; +import org.apache.sling.models.spi.ImplementationPicker; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.ComponentContext; +import org.osgi.util.converter.Converters; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for the per-{@link ResourceResolver} caching of model implementation class lookups (SLING-12217). + * + *

The lookup of the implementation class is the operation that - for resource-type-based adaptation - walks the + * resource super type hierarchy and therefore causes repository access. By counting how often the + * {@link ImplementationPicker} is consulted, these tests verify that the lookup is performed at most once per + * {@code (adaptable type, requested type, resource type)} within a single resolver. + */ +@ExtendWith(MockitoExtension.class) +class ImplementationLookupCachingTest { + + /** The single resolver shared by all resources, providing the (resolver-scoped) cache via its property map. */ + private ResourceResolver resolver; + + private final Map propertyMap = new HashMap<>(); + + /** Counts how often the implementation lookup actually consults the picker. */ + private final AtomicInteger pickCount = new AtomicInteger(); + + private final ImplementationPicker countingPicker = new ImplementationPicker() { + @Override + public Class pick( + @NotNull Class adapterType, Class @NotNull [] implementationsTypes, @NotNull Object adaptable) { + pickCount.incrementAndGet(); + if (adapterType == TestAdapter.class) { + return TestModel.class; + } + // UnmatchedAdapter has registered implementations but the picker selects none -> negative result + return null; + } + }; + + @BeforeEach + void setUp() { + resolver = mock(ResourceResolver.class); + lenient().when(resolver.getPropertyMap()).thenReturn(propertyMap); + } + + private ModelAdapterFactory createFactory(boolean cacheLookups) { + return createFactory(cacheLookups, 100); + } + + private ModelAdapterFactory createFactory(boolean cacheLookups, int cacheSize) { + ComponentContext componentCtx = mock(ComponentContext.class); + BundleContext bundleContext = mock(BundleContext.class); + when(componentCtx.getBundleContext()).thenReturn(bundleContext); + + ModelAdapterFactory factory = new ModelAdapterFactory(); + Map config = new HashMap<>(); + config.put("cache.implementation.lookups", cacheLookups); + config.put("implementation.lookup.cache.size", cacheSize); + factory.activate( + componentCtx, + Converters.standardConverter().convert(config).to(ModelAdapterFactoryConfiguration.class)); + factory.injectAnnotationProcessorFactories = Collections.emptyList(); + factory.injectAnnotationProcessorFactories2 = Collections.emptyList(); + factory.injectors = Collections.emptyList(); + factory.implementationPickers = Collections.singletonList(countingPicker); + // register with an explicit adapter type so that the picker (and hence the lookup) is consulted + factory.adapterImplementations.addAll(TestModel.class, TestAdapter.class); + factory.adapterImplementations.addAll(UnmatchedModel.class, UnmatchedAdapter.class); + return factory; + } + + /** Size of the nested (bounded) lookup cache stored in the resolver property map, or 0 if none was created yet. */ + @SuppressWarnings("unchecked") + private int nestedCacheSize() { + if (propertyMap.isEmpty()) { + return 0; + } + return ((Map) propertyMap.values().iterator().next()).size(); + } + + private Resource resourceOfType(String resourceType) { + Resource resource = mock(Resource.class); + lenient().when(resource.getResourceType()).thenReturn(resourceType); + lenient().when(resource.getResourceResolver()).thenReturn(resolver); + return resource; + } + + @Test + void lookupIsCachedAcrossComponentsWithSameResourceType() { + ModelAdapterFactory factory = createFactory(true); + + TestAdapter first = factory.getAdapter(resourceOfType("my/type"), TestAdapter.class); + TestAdapter second = factory.getAdapter(resourceOfType("my/type"), TestAdapter.class); + + assertNotNull(first); + assertNotNull(second); + assertEquals(1, pickCount.get(), "the implementation lookup must run only once for a given resource type"); + } + + @Test + void canCreateAndCreateShareTheCachedLookup() { + ModelAdapterFactory factory = createFactory(true); + Resource resource = resourceOfType("my/type"); + + // this is the JavaUseProvider pattern: guard with canCreateFromAdaptable, then createModel + assertTrue(factory.canCreateFromAdaptable(resource, TestAdapter.class)); + TestAdapter model = factory.createModel(resource, TestAdapter.class); + + assertNotNull(model); + assertEquals(1, pickCount.get(), "canCreateFromAdaptable and createModel must not look up the type twice"); + } + + @Test + void differentResourceTypesAreNotSharedAndResolveCorrectly() { + ModelAdapterFactory factory = createFactory(true); + + TestAdapter a = factory.getAdapter(resourceOfType("type/a"), TestAdapter.class); + TestAdapter b = factory.getAdapter(resourceOfType("type/b"), TestAdapter.class); + + assertNotNull(a); + assertNotNull(b); + assertEquals(2, pickCount.get(), "different resource types must each trigger their own lookup"); + } + + @Test + void negativeResultIsCached() { + ModelAdapterFactory factory = createFactory(true); + + // UnmatchedAdapter has registered implementations but the picker returns none -> no model + assertFalse(factory.canCreateFromAdaptable(resourceOfType("my/type"), UnmatchedAdapter.class)); + assertFalse(factory.canCreateFromAdaptable(resourceOfType("my/type"), UnmatchedAdapter.class)); + + assertEquals(1, pickCount.get(), "a negative lookup result must be memoized as well"); + } + + @Test + void freshResolverDoesNotShareTheCache() { + ModelAdapterFactory factory = createFactory(true); + + factory.getAdapter(resourceOfType("my/type"), TestAdapter.class); + + // a second resolver has its own property map -> the cache does not carry over + ResourceResolver otherResolver = mock(ResourceResolver.class); + when(otherResolver.getPropertyMap()).thenReturn(new HashMap<>()); + Resource otherResource = mock(Resource.class); + when(otherResource.getResourceType()).thenReturn("my/type"); + when(otherResource.getResourceResolver()).thenReturn(otherResolver); + + factory.getAdapter(otherResource, TestAdapter.class); + + assertEquals(2, pickCount.get(), "a different resolver must not reuse the cached lookup"); + } + + @Test + void leastRecentlyUsedEntryIsEvictedBeyondTheCap() { + ModelAdapterFactory factory = createFactory(true, 2); + + factory.getAdapter(resourceOfType("type/a"), TestAdapter.class); // miss -> cache [a] + factory.getAdapter(resourceOfType("type/b"), TestAdapter.class); // miss -> cache [a, b] + factory.getAdapter(resourceOfType("type/c"), TestAdapter.class); // miss -> evicts a -> cache [b, c] + assertEquals(3, pickCount.get()); + + // type/a was evicted, so it must be looked up again + factory.getAdapter(resourceOfType("type/a"), TestAdapter.class); // miss -> evicts b -> cache [c, a] + assertEquals(4, pickCount.get(), "evicted entries must be recomputed"); + + // type/c is still cached and was made most-recently-used above, so no new lookup + factory.getAdapter(resourceOfType("type/c"), TestAdapter.class); + assertEquals(4, pickCount.get(), "a still-cached entry must not be looked up again"); + + assertEquals(2, nestedCacheSize(), "the cache must never exceed its configured size"); + } + + @Test + void longLivedResolverCacheStaysBounded() { + ModelAdapterFactory factory = createFactory(true, 3); + + for (int i = 0; i < 50; i++) { + factory.getAdapter(resourceOfType("type/" + i), TestAdapter.class); + } + + assertEquals(50, pickCount.get(), "every distinct resource type triggers a lookup once"); + assertEquals(3, nestedCacheSize(), "the cache must stay bounded regardless of resolver lifetime"); + } + + @Test + void cachingCanBeDisabled() { + ModelAdapterFactory factory = createFactory(false); + + factory.getAdapter(resourceOfType("my/type"), TestAdapter.class); + factory.getAdapter(resourceOfType("my/type"), TestAdapter.class); + + assertEquals(2, pickCount.get(), "with caching disabled the lookup runs on every adaptation"); + assertTrue(propertyMap.isEmpty(), "no cache entries must be written when caching is disabled"); + } + + public interface TestAdapter {} + + @Model(adaptables = Resource.class, adapters = TestAdapter.class) + public static class TestModel implements TestAdapter {} + + public interface UnmatchedAdapter {} + + @Model(adaptables = Resource.class, adapters = UnmatchedAdapter.class) + public static class UnmatchedModel implements UnmatchedAdapter {} +} diff --git a/src/test/java/org/apache/sling/models/impl/ImplementsExtendsTest.java b/src/test/java/org/apache/sling/models/impl/ImplementsExtendsTest.java index fe70e0a3..6d6110b6 100644 --- a/src/test/java/org/apache/sling/models/impl/ImplementsExtendsTest.java +++ b/src/test/java/org/apache/sling/models/impl/ImplementsExtendsTest.java @@ -47,6 +47,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.Parameter; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; import org.mockito.invocation.InvocationOnMock; import org.mockito.junit.jupiter.MockitoExtension; @@ -67,9 +70,16 @@ import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; +// run with the implementation-lookup cache (SLING-12217) both enabled and disabled - resolving the alternate +// implementation class via the picker is exactly the path that gets cached, so its result must not change +@ParameterizedClass +@ValueSource(booleans = {true, false}) @ExtendWith(MockitoExtension.class) class ImplementsExtendsTest { + @Parameter + private boolean cacheImplementationLookups; + @Mock private BundleContext bundleContext; @@ -109,7 +119,7 @@ public Object answer(InvocationOnMock invocation) throws Throwable { } }); - factory = AdapterFactoryTest.createModelAdapterFactory(bundleContext); + factory = AdapterFactoryTest.createModelAdapterFactory(bundleContext, cacheImplementationLookups); factory.injectors = Collections.singletonList(new ValueMapInjector()); factory.implementationPickers = Collections.singletonList(firstImplementationPicker); diff --git a/src/test/java/org/apache/sling/models/impl/ModelAdapterFactory_ImplementationPickerOrderTest.java b/src/test/java/org/apache/sling/models/impl/ModelAdapterFactory_ImplementationPickerOrderTest.java index 991aca18..12009345 100644 --- a/src/test/java/org/apache/sling/models/impl/ModelAdapterFactory_ImplementationPickerOrderTest.java +++ b/src/test/java/org/apache/sling/models/impl/ModelAdapterFactory_ImplementationPickerOrderTest.java @@ -18,6 +18,7 @@ */ package org.apache.sling.models.impl; +import java.util.Map; import java.util.function.IntSupplier; import org.apache.sling.api.SlingJakartaHttpServletRequest; @@ -32,6 +33,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.Parameter; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -41,10 +45,18 @@ /** * Tests in which order the implementation pickers are handled depending on service ranking. * For historic/backwards compatibility reasons, higher ranking value means lower priority (inverse to DS behavior). + * + *

Run with the implementation-lookup cache (SLING-12217) both enabled and disabled, as consulting the pickers is + * exactly the operation that gets cached - the picked implementation must be the same in both cases. */ +@ParameterizedClass +@ValueSource(booleans = {true, false}) @ExtendWith({OsgiContextExtension.class, MockitoExtension.class}) class ModelAdapterFactory_ImplementationPickerOrderTest { + @Parameter + boolean cacheImplementationLookups; + final OsgiContext context = new OsgiContext(); @Mock @@ -62,7 +74,8 @@ class ModelAdapterFactory_ImplementationPickerOrderTest { void setUp() { context.registerService(BindingsValuesProvidersByContext.class, bindingsValuesProvidersByContext); context.registerService(AdapterManager.class, adapterManager); - factory = context.registerInjectActivateService(ModelAdapterFactory.class); + factory = context.registerInjectActivateService( + ModelAdapterFactory.class, Map.of("cache.implementation.lookups", cacheImplementationLookups)); ModelAdapterFactoryUtil.addModelsForPackage(context.bundleContext(), Model1.class, Model2.class); } From f2485bf4bd9202855059db09fbdff98697375bc5 Mon Sep 17 00:00:00 2001 From: Joerg Hoh Date: Tue, 30 Jun 2026 15:03:20 +0200 Subject: [PATCH 2/3] Sonar feedback and cleanup --- .../sling/models/impl/AdapterImplementations.java | 3 --- .../apache/sling/models/impl/ModelAdapterFactory.java | 11 +++-------- .../sling/models/impl/ImplementsExtendsTest.java | 8 ++++++++ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/apache/sling/models/impl/AdapterImplementations.java b/src/main/java/org/apache/sling/models/impl/AdapterImplementations.java index 367d0e29..f720fe2b 100644 --- a/src/main/java/org/apache/sling/models/impl/AdapterImplementations.java +++ b/src/main/java/org/apache/sling/models/impl/AdapterImplementations.java @@ -338,9 +338,6 @@ protected static Class getModelClassForResource(final Resource resource, fina return null; } ResourceResolver resolver = resource.getResourceResolver(); - if (resolver == null) { - return null; - } final String originalResourceType = resource.getResourceType(); Class modelClass = getClassFromResourceTypeMap(originalResourceType, map, resolver); if (modelClass != null) { diff --git a/src/main/java/org/apache/sling/models/impl/ModelAdapterFactory.java b/src/main/java/org/apache/sling/models/impl/ModelAdapterFactory.java index 6252608f..d71f1151 100644 --- a/src/main/java/org/apache/sling/models/impl/ModelAdapterFactory.java +++ b/src/main/java/org/apache/sling/models/impl/ModelAdapterFactory.java @@ -412,15 +412,10 @@ private ModelClassException newNoImplementationException(Class requestedType, return null; } final ResourceResolver resolver = resource.getResourceResolver(); - if (resolver == null) { - return null; - } final Map propertyMap = resolver.getPropertyMap(); - Map cache = (Map) propertyMap.get(IMPLEMENTATION_LOOKUP_CACHE_KEY); - if (cache == null) { - cache = createBoundedLookupCache(implementationLookupCacheSize); - propertyMap.put(IMPLEMENTATION_LOOKUP_CACHE_KEY, cache); - } + + Map cache = (Map) propertyMap.computeIfAbsent( + IMPLEMENTATION_LOOKUP_CACHE_KEY, v -> createBoundedLookupCache(implementationLookupCacheSize)); return cache; } diff --git a/src/test/java/org/apache/sling/models/impl/ImplementsExtendsTest.java b/src/test/java/org/apache/sling/models/impl/ImplementsExtendsTest.java index 6d6110b6..04a4d66d 100644 --- a/src/test/java/org/apache/sling/models/impl/ImplementsExtendsTest.java +++ b/src/test/java/org/apache/sling/models/impl/ImplementsExtendsTest.java @@ -30,6 +30,7 @@ import org.apache.sling.api.adapter.AdapterFactory; import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceResolver; import org.apache.sling.api.resource.ValueMap; import org.apache.sling.api.wrappers.ValueMapDecorator; import org.apache.sling.models.factory.ModelClassException; @@ -276,6 +277,13 @@ private Resource getMockResourceWithProps() { Resource res = mock(Resource.class); lenient().when(res.adaptTo(ValueMap.class)).thenReturn(vm); + + ResourceResolver resolver = mock(ResourceResolver.class); + lenient().when(res.getResourceResolver()).thenReturn(resolver); + Map propertyMap = new HashMap<>(); + lenient().when(resolver.getPropertyMap()).thenReturn(propertyMap); + lenient().when(res.getResourceType()).thenReturn("some/resourceType"); + return res; } } From 9bec684763ae6622e69e35fec85dd8bfe1c7a1ea Mon Sep 17 00:00:00 2001 From: Joerg Hoh Date: Tue, 30 Jun 2026 17:52:05 +0200 Subject: [PATCH 3/3] sonar feedback --- .../org/apache/sling/models/impl/ModelAdapterFactory.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/main/java/org/apache/sling/models/impl/ModelAdapterFactory.java b/src/main/java/org/apache/sling/models/impl/ModelAdapterFactory.java index d71f1151..1318cf7e 100644 --- a/src/main/java/org/apache/sling/models/impl/ModelAdapterFactory.java +++ b/src/main/java/org/apache/sling/models/impl/ModelAdapterFactory.java @@ -414,9 +414,8 @@ private ModelClassException newNoImplementationException(Class requestedType, final ResourceResolver resolver = resource.getResourceResolver(); final Map propertyMap = resolver.getPropertyMap(); - Map cache = (Map) propertyMap.computeIfAbsent( + return (Map) propertyMap.computeIfAbsent( IMPLEMENTATION_LOOKUP_CACHE_KEY, v -> createBoundedLookupCache(implementationLookupCacheSize)); - return cache; } private static Map createBoundedLookupCache(final int maxSize) { @@ -438,9 +437,6 @@ protected boolean removeEldestEntry(Map.Entry eldest) { return null; } final String resourceType = resource.getResourceType(); - if (resourceType == null) { - return null; - } return adaptable.getClass().getName() + '|' + requestedType.getName() + '|' + resourceType; }