This is an automated email from the ASF dual-hosted git repository.

sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to refs/heads/master by this push:
     new 1285eb670 UNOMI-1028: Load built-in types from bundles that start 
during the startup scan (#933)
1285eb670 is described below

commit 1285eb670c511786f553dd1aa97f9811632537ae
Author: Serge Huber <[email protected]>
AuthorDate: Thu Oct 29 16:50:33 2026 +0100

    UNOMI-1028: Load built-in types from bundles that start during the startup 
scan (#933)
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../cache/AbstractMultiTypeCachingService.java     |  28 ++-
 .../cache/AbstractMultiTypeCachingServiceTest.java | 224 +++++++++++++++++++++
 2 files changed, 245 insertions(+), 7 deletions(-)

diff --git 
a/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java
 
b/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java
index 3e2e88add..705d41dc2 100644
--- 
a/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java
+++ 
b/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java
@@ -103,6 +103,13 @@ public abstract class AbstractMultiTypeCachingService 
extends AbstractContextAwa
      */
     protected final Map<Long, List<PluginType>> pluginTypes = new 
ConcurrentHashMap<>();
 
+    /**
+     * IDs of the bundles whose predefined items have started loading 
(possibly still in progress on
+     * another thread). A bundle that starts during the startup scan may be 
seen both by the scan and
+     * by the bundle listener; this set makes the second one a no-op.
+     */
+    private final Set<Long> loadedBundleIds = ConcurrentHashMap.newKeySet();
+
     /**
      * Map tracking scheduled tasks for cache refreshes.
      * Key is the task name, value is the ScheduledTask instance.
@@ -163,17 +170,21 @@ public abstract class AbstractMultiTypeCachingService 
extends AbstractContextAwa
     }
 
     /**
-     * Initializes caches, loads predefined items from bundles, registers a 
bundle listener,
-     * loads initial persistence data, and starts refresh timers.
+     * Initializes caches, registers a bundle listener, loads predefined items 
from the bundles
+     * already started, loads initial persistence data, and starts refresh 
timers.
      */
     public void postConstruct() {
         logger.debug("postConstruct {{}}", bundleContext.getBundle());
 
-        // Initialize caches and load predefined items
         initializeCaches();
-        loadPredefinedItems(bundleContext);
+        loadedBundleIds.clear();
 
-        // Process existing bundles
+        // Listen before scanning: a bundle that starts while the scan runs 
(other bundles keep
+        // starting on the Karaf feature thread) is then either scanned or 
notified, never missed.
+        bundleContext.addBundleListener(this);
+
+        // Load predefined items from this bundle, then from the bundles 
already started
+        loadPredefinedItems(bundleContext);
         for (Bundle bundle : bundleContext.getBundles()) {
             if (bundle.getBundleContext() != null &&
                 bundle.getBundleId() != 
bundleContext.getBundle().getBundleId()) {
@@ -181,8 +192,6 @@ public abstract class AbstractMultiTypeCachingService 
extends AbstractContextAwa
             }
         }
 
-        bundleContext.addBundleListener(this);
-
         // Load initial data for all types before starting timers
         loadInitialDataForAllTypes();
 
@@ -218,6 +227,7 @@ public abstract class AbstractMultiTypeCachingService 
extends AbstractContextAwa
      */
     public void preDestroy() {
         bundleContext.removeBundleListener(this);
+        loadedBundleIds.clear();
         shutdownTimers();
         logger.debug("{} service shutdown.", getClass().getSimpleName());
     }
@@ -600,11 +610,14 @@ public abstract class AbstractMultiTypeCachingService 
extends AbstractContextAwa
 
     /**
      * Loads predefined JSON items from bundle resources for all configured 
types.
+     * Does nothing if an earlier call already started loading this bundle (it 
may still be running on
+     * another thread) and the bundle has not stopped since.
      *
      * @param bundleContext the bundle context used to discover predefined 
resources
      */
     protected void loadPredefinedItems(BundleContext bundleContext) {
         if (bundleContext == null) return;
+        if (!loadedBundleIds.add(bundleContext.getBundle().getBundleId())) 
return;
 
         for (CacheableTypeConfig<?> config : getTypeConfigs()) {
             if (config.hasPredefinedItems()) {
@@ -812,6 +825,7 @@ public abstract class AbstractMultiTypeCachingService 
extends AbstractContextAwa
             // Clean up the tracking maps
             pluginContributions.remove(bundleId);
             pluginTypes.remove(bundleId);
+            loadedBundleIds.remove(bundleId);
         }
     }
 
diff --git 
a/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java
 
b/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java
index 4729badf3..727f7ce64 100644
--- 
a/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java
+++ 
b/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java
@@ -31,13 +31,26 @@ import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.MockitoJUnitRunner;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.BundleEvent;
+import org.osgi.framework.BundleListener;
 
+import java.io.File;
 import java.io.Serializable;
+import java.net.URL;
 import java.util.*;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ThreadLocalRandom;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Function;
+import java.util.function.Supplier;
 
 import static org.junit.Assert.*;
 import static org.mockito.ArgumentMatchers.*;
@@ -585,6 +598,217 @@ public class AbstractMultiTypeCachingServiceTest {
         assertFalse(failure.getValue(), 
failure.getValue().contains(SYSTEM_TENANT));
     }
 
+    /**
+     * UNOMI-1028: a plugin bundle that is not yet started when the startup 
scan reaches it, and starts
+     * before the scan ends, must still have its predefined items loaded.
+     */
+    @Test
+    public void testBundleStartingDuringStartupScanIsLoaded() throws Exception 
{
+        BundleLoadingService service = newBundleLoadingService();
+        BundleContext pluginContext = pluginContext();
+        Bundle plugin = pluginContext.getBundle();
+        AtomicBoolean started = new AtomicBoolean();
+        when(plugin.getBundleContext()).thenAnswer(invocation -> {
+            if (started.compareAndSet(false, true)) {
+                // The scan sees the bundle still resolved; it starts right 
after.
+                for (BundleListener listener : service.listeners) {
+                    listener.bundleChanged(new 
BundleEvent(BundleEvent.STARTED, plugin));
+                }
+                return null;
+            }
+            return pluginContext;
+        });
+        Bundle serviceBundle = service.serviceContext.getBundle();
+        when(service.serviceContext.getBundles()).thenReturn(new Bundle[] 
{serviceBundle, plugin});
+
+        service.postConstruct();
+
+        verify(cacheService).put(eq(TEST_ITEM_TYPE), eq("predefined"), 
eq(SYSTEM_TENANT), any(TestSerializable.class));
+    }
+
+    /** A bundle seen by both the startup scan and the listener loads once, 
and again after a restart. */
+    @Test
+    public void testBundleSeenByScanAndListenerLoadsOnceUntilRestarted() 
throws Exception {
+        BundleLoadingService service = newBundleLoadingService();
+        BundleContext pluginContext = pluginContext();
+        Bundle plugin = pluginContext.getBundle();
+        when(plugin.getBundleContext()).thenReturn(pluginContext);
+        Bundle serviceBundle = service.serviceContext.getBundle();
+        when(service.serviceContext.getBundles()).thenReturn(new Bundle[] 
{serviceBundle, plugin});
+
+        service.postConstruct();
+        // The bundle was STARTING during the scan, so its STARTED event 
follows.
+        service.bundleChanged(new BundleEvent(BundleEvent.STARTED, plugin));
+        verify(cacheService, times(1)).put(eq(TEST_ITEM_TYPE), 
eq("predefined"), eq(SYSTEM_TENANT), any(TestSerializable.class));
+
+        service.bundleChanged(new BundleEvent(BundleEvent.STOPPING, plugin));
+        service.bundleChanged(new BundleEvent(BundleEvent.STARTED, plugin));
+        verify(cacheService, times(2)).put(eq(TEST_ITEM_TYPE), 
eq("predefined"), eq(SYSTEM_TENANT), any(TestSerializable.class));
+    }
+
+    /**
+     * UNOMI-1028 with real threads: the startup scan runs on one thread while 
a plugin bundle starts
+     * on another at a random moment, as the Blueprint extender and Karaf 
feature threads do. Like the
+     * OSGi framework, the starter makes the bundle active before notifying 
the listeners registered at
+     * that time. Every round must load the plugin's items exactly once.
+     */
+    @Test
+    public void 
testBundleStartingConcurrentlyWithStartupScanLoadsExactlyOnce() throws 
Exception {
+        final int rounds = 300;
+        // Bundles the scan walks after the plugin, about a microsecond each 
(the real scan reads JSON),
+        // so that the plugin can start while the rest of the scan runs.
+        final int laterBundles = 200;
+
+        ExecutionContextManager systemContext = 
mock(ExecutionContextManager.class, withSettings().stubOnly());
+        doAnswer(invocation -> ((Supplier<?>) invocation.getArgument(0)).get())
+                .when(systemContext).executeAsSystem(any(Supplier.class));
+        MultiTypeCacheService cache = mock(MultiTypeCacheService.class, 
withSettings().stubOnly());
+
+        File jsonFile = File.createTempFile("predefined", ".json");
+        jsonFile.deleteOnExit();
+        URL json = jsonFile.toURI().toURL();
+        AtomicBoolean pluginStarted = new AtomicBoolean();
+        Bundle plugin = mock(Bundle.class, withSettings().stubOnly());
+        BundleContext pluginContext = mock(BundleContext.class, 
withSettings().stubOnly());
+        when(pluginContext.getBundle()).thenReturn(plugin);
+        when(plugin.getBundleId()).thenReturn(2L);
+        when(plugin.getBundleContext()).thenAnswer(invocation -> 
pluginStarted.get() ? pluginContext : null);
+        when(plugin.findEntries(eq("META-INF/cxs/test"), eq("*.json"), 
eq(true)))
+                .thenAnswer(invocation -> 
Collections.enumeration(Collections.singletonList(json)));
+        Bundle resolvedBundle = mock(Bundle.class, withSettings().stubOnly());
+        when(resolvedBundle.getBundleContext()).thenAnswer(invocation -> {
+            spinFor(1_000);
+            return null;
+        });
+
+        // One service, restarted every round: creating mocks per round would 
dominate the run time.
+        BundleLoadingService service = new BundleLoadingService();
+        service.setContextManager(systemContext);
+        service.setCacheService(cache);
+        Bundle[] bundles = new Bundle[laterBundles + 2];
+        bundles[0] = service.serviceContext.getBundle();
+        bundles[1] = plugin;
+        Arrays.fill(bundles, 2, bundles.length, resolvedBundle);
+        AtomicBoolean scanStarted = new AtomicBoolean();
+        when(service.serviceContext.getBundles()).thenAnswer(invocation -> {
+            scanStarted.set(true);
+            return bundles;
+        });
+
+        ExecutorService starter = Executors.newSingleThreadExecutor();
+        int missed = 0;
+        int loadedTwice = 0;
+        try {
+            for (int round = 0; round < rounds; round++) {
+                service.preDestroy();
+                service.loads.set(0);
+                pluginStarted.set(false);
+                scanStarted.set(false);
+
+                // Start the plugin somewhere between the scan's start and 
shortly after its end.
+                long delayNanos = 
ThreadLocalRandom.current().nextLong(TimeUnit.MICROSECONDS.toNanos(300));
+                Future<?> start = starter.submit(() -> {
+                    // Spin rather than block: waking a parked thread takes 
longer than the scan.
+                    while (!scanStarted.get()) {
+                        Thread.onSpinWait();
+                    }
+                    spinFor(delayNanos);
+                    pluginStarted.set(true);
+                    for (BundleListener listener : service.listeners) {
+                        listener.bundleChanged(new 
BundleEvent(BundleEvent.STARTED, plugin));
+                    }
+                    return null;
+                });
+                service.postConstruct();
+                start.get(5, TimeUnit.SECONDS);
+
+                if (service.loads.get() == 0) {
+                    missed++;
+                } else if (service.loads.get() > 1) {
+                    loadedTwice++;
+                }
+            }
+        } finally {
+            starter.shutdownNow();
+        }
+
+        assertEquals("rounds where the plugin's items were never loaded", 0, 
missed);
+        assertEquals("rounds where the plugin's items were loaded twice", 0, 
loadedTwice);
+    }
+
+    private static void spinFor(long nanos) {
+        long until = System.nanoTime() + nanos;
+        while (System.nanoTime() < until) {
+            Thread.onSpinWait();
+        }
+    }
+
+    /** Service whose only type loads one predefined item per bundle, from 
{@code META-INF/cxs/test}. */
+    private static class BundleLoadingService extends 
AbstractMultiTypeCachingService {
+        final BundleContext serviceContext = mock(BundleContext.class);
+        final List<BundleListener> listeners = new CopyOnWriteArrayList<>();
+        final AtomicInteger loads = new AtomicInteger();
+
+        BundleLoadingService() {
+            Bundle serviceBundle = mock(Bundle.class);
+            when(serviceBundle.getBundleId()).thenReturn(1L);
+            when(serviceBundle.getBundleContext()).thenReturn(serviceContext);
+            when(serviceContext.getBundle()).thenReturn(serviceBundle);
+            doAnswer(invocation -> listeners.add(invocation.getArgument(0)))
+                    
.when(serviceContext).addBundleListener(any(BundleListener.class));
+            doAnswer(invocation -> listeners.remove(invocation.getArgument(0)))
+                    
.when(serviceContext).removeBundleListener(any(BundleListener.class));
+            setBundleContext(serviceContext);
+        }
+
+        @Override
+        protected Set<CacheableTypeConfig<?>> getTypeConfigs() {
+            return 
Collections.singleton(CacheableTypeConfig.<TestSerializable>builder(TestSerializable.class,
 TEST_ITEM_TYPE, "test")
+                    .withIdExtractor(TestSerializable::getId)
+                    .withStreamProcessor((bundleContext, url, inputStream) -> {
+                        loads.incrementAndGet();
+                        return new TestSerializable("predefined", null);
+                    })
+                    .build());
+        }
+
+        @Override
+        protected void loadInitialDataForAllTypes() {
+        }
+
+        @Override
+        protected void initializeTimers() {
+        }
+
+        @Override
+        protected void shutdownTimers() {
+        }
+    }
+
+    private BundleLoadingService newBundleLoadingService() {
+        doAnswer(invocation -> ((Supplier<?>) invocation.getArgument(0)).get())
+                .when(contextManager).executeAsSystem(any(Supplier.class));
+        BundleLoadingService service = new BundleLoadingService();
+        service.setContextManager(contextManager);
+        service.setCacheService(cacheService);
+        service.setPersistenceService(persistenceService);
+        return service;
+    }
+
+    /** Context of a plugin bundle (id 2) shipping one predefined item. */
+    private BundleContext pluginContext() throws Exception {
+        File json = File.createTempFile("predefined", ".json");
+        json.deleteOnExit();
+        URL url = json.toURI().toURL();
+        Bundle plugin = mock(Bundle.class);
+        when(plugin.getBundleId()).thenReturn(2L);
+        when(plugin.findEntries(eq("META-INF/cxs/test"), eq("*.json"), 
eq(true)))
+                .thenAnswer(invocation -> 
Collections.enumeration(Collections.singletonList(url)));
+        BundleContext pluginContext = mock(BundleContext.class);
+        when(pluginContext.getBundle()).thenReturn(plugin);
+        return pluginContext;
+    }
+
     /** Records the tenants the action runs under; switching to {@code 
failingTenant} throws instead. */
     private void recordTenantsRun(List<String> tenantsRun, String 
failingTenant) {
         doAnswer(invocation -> {

Reply via email to