Patrique Legault created SLING-13367:
----------------------------------------

             Summary: OSGiServiceInjector: cache service references and service 
objects instead of querying the service registry for every injection
                 Key: SLING-13367
                 URL: https://issues.apache.org/jira/browse/SLING-13367
             Project: Sling
          Issue Type: Improvement
          Components: Sling Models
    Affects Versions: Models Implementation 2.0.2
            Reporter: Patrique Legault


h3. Problem
{{OSGiServiceInjector}} calls {{BundleContext.getServiceReferences()}} and 
{{BundleContext.getService()}} for *every* {{@OSGiService}} injection of 
*every* model instance, and {{ungetService()}} again from the model's disposal 
callback. Every one of these calls goes through the framework service registry. 
On Apache Felix, {{getServiceReferences()}} additionally gets, invokes and 
ungets every registered {{org.osgi.framework.hooks.service.FindHook}} on each 
call.

When many models are instantiated concurrently, for example pages that adapt 
many resources to models injecting OSGi services, request threads end up 
serialised on the service registry. Once a hook's usage count drops back to 0, 
Felix discards its {{ServiceRegistry$ServiceHolder}}. The next caller creates a 
new holder with a new {{CountDownLatch}}, and every concurrent caller parks on 
that latch until the holder is ready. The number of registry round-trips grows 
with the number of model instantiations times the number of injected services.

h3. Observed
A thread dump taken under load (Felix 7, JDK 21) showed 79 of 180 request 
threads inside {{ServiceRegistry.getService()}} called from 
{{Felix.getServiceReferences()}}, i.e. while obtaining a {{FindHook}}. 73 of 
them were parked on a {{ServiceHolder}} latch, and 64 came from 
{{OSGiServiceInjector}}:
{noformat}
java.lang.Thread.State: WAITING (parking)
  at jdk.internal.misc.Unsafe.park(Native Method)
  - parking to wait for <0x00000007f3803288> (a 
java.util.concurrent.CountDownLatch$Sync)
  at java.util.concurrent.CountDownLatch.await(CountDownLatch.java:230)
  at 
org.apache.felix.framework.ServiceRegistry.getService(ServiceRegistry.java:380)
  at org.apache.felix.framework.Felix.getService(Felix.java:3984)
  at org.apache.felix.framework.Felix.getServiceReferences(Felix.java:3895)     
 <-- obtaining the FindHook
  at 
org.apache.felix.framework.Felix.getAllowedServiceReferences(Felix.java:3947)
  at 
org.apache.felix.framework.BundleContextImpl.getServiceReferences(BundleContextImpl.java:414)
  at 
org.apache.sling.models.impl.injectors.OSGiServiceInjector.getService(OSGiServiceInjector.java:124)
  at 
org.apache.sling.models.impl.injectors.OSGiServiceInjector.getValue(OSGiServiceInjector.java:198)
  at 
org.apache.sling.models.impl.injectors.OSGiServiceInjector.getValue(OSGiServiceInjector.java:111)
  at 
org.apache.sling.models.impl.ModelAdapterFactory.injectElementInternal(ModelAdapterFactory.java:600)
  at 
org.apache.sling.models.impl.ModelAdapterFactory.injectElement(ModelAdapterFactory.java:535)
  at 
org.apache.sling.models.impl.ModelAdapterFactory.createObject(ModelAdapterFactory.java:754)
  at 
org.apache.sling.models.impl.ModelAdapterFactory.internalCreateModel(ModelAdapterFactory.java:409)
  at 
org.apache.sling.models.impl.ModelAdapterFactory.getAdapter(ModelAdapterFactory.java:219)
  at 
org.apache.sling.adapter.internal.AdapterManagerImpl.getAdapter(AdapterManagerImpl.java:121)
  at 
org.apache.sling.api.adapter.SlingAdaptable.adaptTo(SlingAdaptable.java:104)
  ...
{noformat}
Other request threads in the same dump were creating a new {{ServiceHolder}} 
(and latch) for the hook, because its previous usage had been fully released:
{noformat}
java.lang.Thread.State: RUNNABLE
  at java.util.concurrent.CountDownLatch.<init>(CountDownLatch.java:199)
  at 
org.apache.felix.framework.ServiceRegistry$ServiceHolder.<init>(ServiceRegistry.java:786)
  at 
org.apache.felix.framework.ServiceRegistry.getService(ServiceRegistry.java:357)
  at org.apache.felix.framework.Felix.getService(Felix.java:3984)
  at org.apache.felix.framework.Felix.getServiceReferences(Felix.java:3895)
  at 
org.apache.felix.framework.Felix.getAllowedServiceReferences(Felix.java:3947)
  at 
org.apache.felix.framework.BundleContextImpl.getServiceReferences(BundleContextImpl.java:414)
  at 
org.apache.sling.models.impl.injectors.OSGiServiceInjector.getService(OSGiServiceInjector.java:124)
  ...
{noformat}
The set of services a model can inject only changes when a service is 
registered, modified or unregistered, so doing a registry lookup per injection 
is avoidable.

h3. Proposed change
Add a package-private {{OSGiServiceCache}}, used by {{OSGiServiceInjector}}:
* *References* are cached per (requesting bundle context, service class, 
filter) and sorted by descending service ranking once.
** Empty results are cached too, so repeated lookups of absent {{@Optional}} 
services do not hit the registry either.
** Any {{ServiceEvent}} ({{REGISTERED}}, {{MODIFIED}}, {{UNREGISTERING}}) for a 
service evicts all cached lookups for each of its {{objectClass}} names. The 
cache uses an {{AllServiceListener}}, so events are not filtered by class-space 
compatibility.
** Lookups with an invalid filter are not cached.
* *Service objects* are obtained once per (requesting bundle context, 
reference) and kept, similar to a static DS reference.
** They are released when the service is unregistered, when the requesting 
(model) bundle stops ({{SynchronousBundleListener}}, {{STOPPING}}), or when the 
injector is deactivated.
** Cached services are no longer registered in the model's disposal callback. 
Services obtained outside the cache still are.
* *Races:* the per-class map is resolved before the registry is queried, so a 
result computed concurrently with an eviction ends up in the evicted map and is 
never served. If a cached reference turns out to be unregistered 
({{ServiceReference.getBundle() == null}}), it is evicted and released on first 
use.
* *Fallback:* while the cache is not active (before activation or after 
deactivation), the injector behaves exactly as before: lookup, {{getService}}, 
and {{ungetService}} via the disposal callback.

There is no API or exported-package change, and the bnd baseline passes.

h3. Behaviour changes to discuss
* Bundle-scoped {{ServiceFactory}} services now keep one usage per model bundle 
for as long as the service is registered. Previously, the usage count went to 0 
after each model's disposal callback, which could trigger 
{{ServiceFactory.ungetService}} (and, for delayed DS components, deactivation) 
between requests. The injector keeps using {{BundleContext.getService}}, so 
prototype-scope semantics are unchanged.
* If a {{FindHook}} changes its visibility decisions *without* a service event 
for the affected service class, and the requesting bundle is not stopped, the 
change only shows up after the next such event. There may be setups where this 
matters, such as dynamic region or subsystem filtering.

h3. Testing
* {{mvn verify}} on JDK 21: 301 unit tests and 28 integration tests pass; 
spotless and bnd baseline pass.
* New {{OSGiServiceCacheTest}} (13 tests) and 6 new tests in 
{{OSGiInjectionTest}} cover:
** caching across model instances and collection injection;
** ranking order and negative caching;
** eviction on service events, and release on {{UNREGISTERING}} and bundle 
{{STOPPING}};
** stale cached references;
** deactivation fallback.
* Local microbenchmark, synthetic and indicative only: Felix 7.0.5 + SCR, one 
no-op delayed-DS {{FindHook}}, 3 candidate services, 64 threads.
** It measured about 19-21 µs per injection with the current lookup+get path, 
vs about 0.1-0.6 µs with the cache.
** It did not reproduce the latch parking from the thread dump, which depends 
on how expensive it is to obtain the real hooks.

Pull request: 
https://github.com/apache/sling-org-apache-sling-models-impl/pull/89




--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to