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

popduke pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/bifromq.git


The following commit(s) were added to refs/heads/main by this push:
     new 41313fdde feat: make plugin FQN configs optional with 
strict-or-graceful loading (#237)
41313fdde is described below

commit 41313fdde2486b4ff7e883c070592de3f09d8f19
Author: liaodongnian <[email protected]>
AuthorDate: Mon Mar 30 17:19:03 2026 +0800

    feat: make plugin FQN configs optional with strict-or-graceful loading 
(#237)
    
    * feat(core): make plugin FQN configs optional with strict-or-graceful 
loading
    Make authProviderFQN, resourceThrottlerFQN, and settingProviderFQN in 
standalone.yml optional to improve local dev/test ergonomics.
    - If a *FQN field is explicitly set, treat it as a hard requirement:
      BifroMQ will fail fast on startup if the plugin class is missing or fails 
to initialize.
    - If the field is omitted, apply auto-selection logic:
      1. Discover all registered custom plugins for the extension point.
      2. Sort by plugin key (byte-wise alphabetical) and pick the first.
      3. Fall back to built-in default only if no custom plugins exist.
    This provides a clear contract for production (explicit = strict)
    while keeping local development simple and low-friction.
    Fixes #236
    Signed-off-by: liaodongnian <[email protected]>
---
 .../plugin/authprovider/AuthProviderManager.java   |  16 +-
 .../authprovider/AuthProviderPluginException.java  |  31 +++
 .../authprovider/AuthProviderManagerTest.java      |  82 +++++++-
 .../ResourceThrottlerException.java                |  31 +++
 .../ResourceThrottlerManager.java                  |  17 +-
 .../ResourceThrottlerManagerTest.java              | 222 +++++++++++++++++++++
 .../TenantResourceThrottlerManagerTest.java        |   5 +-
 .../settingprovider/SettingProviderException.java  |  31 +++
 .../settingprovider/SettingProviderManager.java    |  21 +-
 .../SettingProviderManagerTest.java                | 108 ++++++++--
 10 files changed, 514 insertions(+), 50 deletions(-)

diff --git 
a/bifromq-plugin/bifromq-plugin-auth-provider-helper/src/main/java/org/apache/bifromq/plugin/authprovider/AuthProviderManager.java
 
b/bifromq-plugin/bifromq-plugin-auth-provider-helper/src/main/java/org/apache/bifromq/plugin/authprovider/AuthProviderManager.java
index a22f15066..84687b5e4 100644
--- 
a/bifromq-plugin/bifromq-plugin-auth-provider-helper/src/main/java/org/apache/bifromq/plugin/authprovider/AuthProviderManager.java
+++ 
b/bifromq-plugin/bifromq-plugin-auth-provider-helper/src/main/java/org/apache/bifromq/plugin/authprovider/AuthProviderManager.java
@@ -40,6 +40,7 @@ import 
org.apache.bifromq.plugin.settingprovider.ISettingProvider;
 import org.apache.bifromq.type.ClientInfo;
 import io.micrometer.core.instrument.Timer;
 import java.util.Map;
+import java.util.TreeMap;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.stream.Collectors;
@@ -64,17 +65,22 @@ public class AuthProviderManager implements IAuthProvider, 
AutoCloseable {
         this.settingProvider = settingProvider;
         this.eventCollector = eventCollector;
         Map<String, IAuthProvider> availAuthProviders = 
pluginMgr.getExtensions(IAuthProvider.class)
-            .stream().collect(Collectors.toMap(e -> e.getClass().getName(), e 
-> e));
+            .stream().collect(Collectors.toMap(e -> e.getClass().getName(), e 
-> e,
+                        (k,v) -> v, TreeMap::new));
         if (availAuthProviders.isEmpty()) {
             pluginLog.warn("No auth provider plugin available, use DEV ONLY 
one instead");
             delegate = new DevOnlyAuthProvider();
         } else {
             if (authProviderFQN == null) {
-                pluginLog.warn("Auth provider plugin type not specified, use 
DEV ONLY one instead");
-                delegate = new DevOnlyAuthProvider();
+                if (availAuthProviders.size() > 1) {
+                    pluginLog.info("Auth provider plugin type not specified, 
use the first found");
+                }
+                String firstAuthProviderFQN = 
availAuthProviders.keySet().iterator().next();
+                pluginLog.info("Auth provider plugin loaded: {}", 
firstAuthProviderFQN);
+                delegate = availAuthProviders.get(firstAuthProviderFQN);
             } else if (!availAuthProviders.containsKey(authProviderFQN)) {
-                pluginLog.warn("Auth provider plugin type '{}' not found, use 
DEV ONLY one instead", authProviderFQN);
-                delegate = new DevOnlyAuthProvider();
+                pluginLog.warn("Auth provider plugin type '{}' not found, so 
the system will shut down.", authProviderFQN);
+                throw new AuthProviderPluginException("Auth provider plugin 
type '%s' not found, so the system will shut down.", authProviderFQN);
             } else {
                 pluginLog.info("Auth provider plugin type: {}", 
authProviderFQN);
                 delegate = availAuthProviders.get(authProviderFQN);
diff --git 
a/bifromq-plugin/bifromq-plugin-auth-provider-helper/src/main/java/org/apache/bifromq/plugin/authprovider/AuthProviderPluginException.java
 
b/bifromq-plugin/bifromq-plugin-auth-provider-helper/src/main/java/org/apache/bifromq/plugin/authprovider/AuthProviderPluginException.java
new file mode 100644
index 000000000..42b404ecc
--- /dev/null
+++ 
b/bifromq-plugin/bifromq-plugin-auth-provider-helper/src/main/java/org/apache/bifromq/plugin/authprovider/AuthProviderPluginException.java
@@ -0,0 +1,31 @@
+/*
+ * 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.bifromq.plugin.authprovider;
+
+import org.pf4j.util.StringUtils;
+
+public class AuthProviderPluginException extends RuntimeException {
+
+    public AuthProviderPluginException(String message, Object... args) {
+        super(StringUtils.format(message, args));
+    }
+
+}
diff --git 
a/bifromq-plugin/bifromq-plugin-auth-provider-helper/src/test/java/org/apache/bifromq/plugin/authprovider/AuthProviderManagerTest.java
 
b/bifromq-plugin/bifromq-plugin-auth-provider-helper/src/test/java/org/apache/bifromq/plugin/authprovider/AuthProviderManagerTest.java
index 04690bb67..377b24347 100644
--- 
a/bifromq-plugin/bifromq-plugin-auth-provider-helper/src/test/java/org/apache/bifromq/plugin/authprovider/AuthProviderManagerTest.java
+++ 
b/bifromq-plugin/bifromq-plugin-auth-provider-helper/src/test/java/org/apache/bifromq/plugin/authprovider/AuthProviderManagerTest.java
@@ -41,6 +41,7 @@ import 
org.apache.bifromq.plugin.authprovider.type.MQTT5AuthResult;
 import org.apache.bifromq.plugin.authprovider.type.MQTT5ExtendedAuthData;
 import org.apache.bifromq.plugin.authprovider.type.MQTT5ExtendedAuthResult;
 import org.apache.bifromq.plugin.authprovider.type.MQTTAction;
+import org.apache.bifromq.plugin.authprovider.type.Ok;
 import org.apache.bifromq.plugin.authprovider.type.PubAction;
 import org.apache.bifromq.plugin.authprovider.type.Reject;
 import org.apache.bifromq.plugin.authprovider.type.SubAction;
@@ -52,6 +53,8 @@ import org.apache.bifromq.type.ClientInfo;
 import io.micrometer.core.instrument.MeterRegistry;
 import io.micrometer.core.instrument.Metrics;
 import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.concurrent.CompletableFuture;
 import lombok.extern.slf4j.Slf4j;
@@ -133,18 +136,52 @@ public class AuthProviderManagerTest {
     }
 
     @Test
+    public void pluginNotSpecifiedWithSingleProvider() {
+        manager = new AuthProviderManager(null, pluginManager, 
settingProvider, eventCollector);
+        when(mockProvider.auth(mockAuth3Data)).thenReturn(
+            CompletableFuture.completedFuture(MQTT3AuthResult.newBuilder()
+                
.setReject(Reject.newBuilder().setCode(Reject.Code.BadPass).build()).build()));
+        MQTT3AuthResult result = manager.auth(mockAuth3Data).join();
+        assertEquals(result.getTypeCase(), MQTT3AuthResult.TypeCase.REJECT);
+        assertEquals(result.getReject().getCode(), Reject.Code.BadPass);
+        manager.close();
+    }
+
+    @Test
+    public void pluginNotSpecifiedWithMultipleProviders() {
+        IAuthProvider provider1 = new FirstTestAuthProvider();
+        IAuthProvider provider2 = new SecondTestAuthProvider();
+
+        when(pluginManager.getExtensions(IAuthProvider.class)).thenReturn(
+            Arrays.asList(provider1, provider2));
+        manager = new AuthProviderManager(null, pluginManager, 
settingProvider, eventCollector);
+
+        MQTT3AuthResult result = manager.auth(mockAuth3Data).join();
+        // Deterministically selects the provider with lexicographically 
smallest class name
+        assertEquals(result.getOk().getTenantId(), "FirstProvider");
+        manager.close();
+    }
+
+    @Test
+    public void pluginNotSpecifiedWithMultipleSortByKeyProviders() {
+        IAuthProvider provider1 = new FirstTestAuthProvider();
+        IAuthProvider provider2 = new SecondTestAuthProvider();
+
+        when(pluginManager.getExtensions(IAuthProvider.class)).thenReturn(
+                Arrays.asList(provider2, provider1));
+        manager = new AuthProviderManager(null, pluginManager, 
settingProvider, eventCollector);
+
+        MQTT3AuthResult result = manager.auth(mockAuth3Data).join();
+        // Deterministically selects the provider with lexicographically 
smallest class name
+        assertEquals(result.getOk().getTenantId(), "FirstProvider");
+        manager.close();
+    }
+
+
+    @Test(expectedExceptions = AuthProviderPluginException.class)
     public void pluginNotFound() {
         manager = new AuthProviderManager("Fake", pluginManager, 
settingProvider, eventCollector);
-        MQTT3AuthResult result = manager.auth(mockAuth3Data).join();
-        assertEquals(result.getTypeCase(), MQTT3AuthResult.TypeCase.OK);
-        assertEquals(result.getOk().getTenantId(), "DevOnly");
 
-        boolean allow = manager.check(ClientInfo.getDefaultInstance(), 
MQTTAction.newBuilder()
-            .setSub(SubAction.getDefaultInstance()).build()).join();
-        assertTrue(allow);
-        allow = manager.check(ClientInfo.getDefaultInstance(), 
MQTTAction.newBuilder()
-            .setSub(SubAction.getDefaultInstance()).build()).join();
-        assertTrue(allow);
         manager.close();
     }
 
@@ -404,4 +441,31 @@ public class AuthProviderManagerTest {
         assertEquals(meterRegistry.find(CALL_FAIL_COUNTER).tag(TAG_METHOD, 
"AuthProvider/check").counter().count(),
             0);
     }
+
+    static class FirstTestAuthProvider implements IAuthProvider {
+        @Override
+        public CompletableFuture<MQTT3AuthResult> auth(MQTT3AuthData authData) 
{
+            return 
CompletableFuture.completedFuture(MQTT3AuthResult.newBuilder()
+                
.setOk(Ok.newBuilder().setTenantId("FirstProvider").build()).build());
+        }
+
+        @Override
+        public CompletableFuture<Boolean> check(ClientInfo client, MQTTAction 
action) {
+            return CompletableFuture.completedFuture(true);
+        }
+    }
+
+    static class SecondTestAuthProvider implements IAuthProvider {
+        @Override
+        public CompletableFuture<MQTT3AuthResult> auth(MQTT3AuthData authData) 
{
+            return 
CompletableFuture.completedFuture(MQTT3AuthResult.newBuilder()
+                
.setOk(Ok.newBuilder().setTenantId("SecondProvider").build()).build());
+        }
+
+        @Override
+        public CompletableFuture<Boolean> check(ClientInfo client, MQTTAction 
action) {
+            return CompletableFuture.completedFuture(true);
+        }
+
+    }
 }
diff --git 
a/bifromq-plugin/bifromq-plugin-resource-throttler-helper/src/main/java/org/apache/bifromq/plugin/resourcethrottler/ResourceThrottlerException.java
 
b/bifromq-plugin/bifromq-plugin-resource-throttler-helper/src/main/java/org/apache/bifromq/plugin/resourcethrottler/ResourceThrottlerException.java
new file mode 100644
index 000000000..a637c06a6
--- /dev/null
+++ 
b/bifromq-plugin/bifromq-plugin-resource-throttler-helper/src/main/java/org/apache/bifromq/plugin/resourcethrottler/ResourceThrottlerException.java
@@ -0,0 +1,31 @@
+/*
+ * 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.bifromq.plugin.resourcethrottler;
+
+import org.pf4j.util.StringUtils;
+
+public class ResourceThrottlerException extends RuntimeException {
+
+    public ResourceThrottlerException(String message, Object... args) {
+        super(StringUtils.format(message, args));
+    }
+
+}
diff --git 
a/bifromq-plugin/bifromq-plugin-resource-throttler-helper/src/main/java/org/apache/bifromq/plugin/resourcethrottler/ResourceThrottlerManager.java
 
b/bifromq-plugin/bifromq-plugin-resource-throttler-helper/src/main/java/org/apache/bifromq/plugin/resourcethrottler/ResourceThrottlerManager.java
index 9a3818130..f0197bb37 100644
--- 
a/bifromq-plugin/bifromq-plugin-resource-throttler-helper/src/main/java/org/apache/bifromq/plugin/resourcethrottler/ResourceThrottlerManager.java
+++ 
b/bifromq-plugin/bifromq-plugin-resource-throttler-helper/src/main/java/org/apache/bifromq/plugin/resourcethrottler/ResourceThrottlerManager.java
@@ -23,6 +23,7 @@ import io.micrometer.core.instrument.Counter;
 import io.micrometer.core.instrument.Metrics;
 import io.micrometer.core.instrument.Timer;
 import java.util.Map;
+import java.util.TreeMap;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.stream.Collectors;
 import lombok.extern.slf4j.Slf4j;
@@ -41,18 +42,24 @@ public class ResourceThrottlerManager implements 
IResourceThrottler, AutoCloseab
     public ResourceThrottlerManager(String resourceThrottlerFQN, PluginManager 
pluginMgr) {
         Map<String, IResourceThrottler> availResourceThrottlers =
             pluginMgr.getExtensions(IResourceThrottler.class).stream()
-                .collect(Collectors.toMap(e -> e.getClass().getName(), e -> 
e));
+                .collect(Collectors.toMap(e -> e.getClass().getName(), e -> e,
+                        (k,v) -> v, TreeMap::new));
         if (availResourceThrottlers.isEmpty()) {
             pluginLog.warn("No resource throttler plugin available, use DEV 
ONLY one instead");
             delegate = new DevOnlyResourceThrottler();
         } else {
             if (resourceThrottlerFQN == null) {
-                pluginLog.warn("Resource throttler type class not specified, 
use DEV ONLY one instead");
-                delegate = new DevOnlyResourceThrottler();
+                if (availResourceThrottlers.size() > 1) {
+                    pluginLog.info("Resource throttler plugin type not 
specified, use the first found");
+                }
+                String firstResourceThrottlerFQN = 
availResourceThrottlers.keySet().iterator().next();
+                pluginLog.info("Resource throttler plugin loaded: {}", 
firstResourceThrottlerFQN);
+                delegate = 
availResourceThrottlers.get(firstResourceThrottlerFQN);
             } else if 
(!availResourceThrottlers.containsKey(resourceThrottlerFQN)) {
-                pluginLog.warn("Resource throttler type '{}' not found, use 
DEV ONLY one instead",
+                pluginLog.warn("Resource throttler type '{}' not found, so the 
system will shut down.",
                     resourceThrottlerFQN);
-                delegate = new DevOnlyResourceThrottler();
+                throw new ResourceThrottlerException("Resource throttler type 
'%s' not found, so the system will shut down.",
+                        resourceThrottlerFQN);
             } else {
                 pluginLog.info("Resource throttler loaded: {}", 
resourceThrottlerFQN);
                 delegate = availResourceThrottlers.get(resourceThrottlerFQN);
diff --git 
a/bifromq-plugin/bifromq-plugin-resource-throttler-helper/src/test/java/org/apache/bifromq/plugin/resourcethrottler/ResourceThrottlerManagerTest.java
 
b/bifromq-plugin/bifromq-plugin-resource-throttler-helper/src/test/java/org/apache/bifromq/plugin/resourcethrottler/ResourceThrottlerManagerTest.java
new file mode 100644
index 000000000..39f7349b7
--- /dev/null
+++ 
b/bifromq-plugin/bifromq-plugin-resource-throttler-helper/src/test/java/org/apache/bifromq/plugin/resourcethrottler/ResourceThrottlerManagerTest.java
@@ -0,0 +1,222 @@
+/*
+ * 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.bifromq.plugin.resourcethrottler;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Metrics;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.concurrent.CompletableFuture;
+import lombok.extern.slf4j.Slf4j;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.pf4j.PluginManager;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+@Slf4j
+public class ResourceThrottlerManagerTest {
+    @Mock
+    private PluginManager pluginManager;
+    @Mock
+    private IResourceThrottler mockResourceThrottler;
+    private MeterRegistry meterRegistry;
+    private final String tenantId = "testTenant";
+    private final TenantResourceType resourceType = 
TenantResourceType.TotalConnections;
+    private ResourceThrottlerManager manager;
+    private AutoCloseable closeable;
+
+    @BeforeMethod
+    public void setup() {
+        meterRegistry = new SimpleMeterRegistry();
+        Metrics.globalRegistry.add(meterRegistry);
+        closeable = MockitoAnnotations.openMocks(this);
+        when(pluginManager.getExtensions(IResourceThrottler.class)).thenReturn(
+            Collections.singletonList(mockResourceThrottler));
+    }
+
+    @AfterMethod
+    public void tearDown() throws Exception {
+        closeable.close();
+        meterRegistry.clear();
+        Metrics.globalRegistry.clear();
+    }
+
+    @Test
+    public void devOnlyMode() {
+        
when(pluginManager.getExtensions(IResourceThrottler.class)).thenReturn(Collections.emptyList());
+        manager = new ResourceThrottlerManager(null, pluginManager);
+        for (TenantResourceType type : TenantResourceType.values()) {
+            assertTrue(manager.hasResource(tenantId, type));
+        }
+        manager.close();
+    }
+
+    @Test
+    public void pluginSpecified() {
+        manager = new 
ResourceThrottlerManager(mockResourceThrottler.getClass().getName(), 
pluginManager);
+        when(mockResourceThrottler.hasResource(anyString(), 
any(TenantResourceType.class))).thenReturn(false);
+        boolean hasResource = manager.hasResource(tenantId, resourceType);
+        assertFalse(hasResource);
+        manager.close();
+    }
+
+    @Test
+    public void pluginNotSpecifiedWithSingleProvider() {
+        manager = new ResourceThrottlerManager(null, pluginManager);
+        when(mockResourceThrottler.hasResource(anyString(), 
any(TenantResourceType.class))).thenReturn(false);
+        boolean hasResource = manager.hasResource(tenantId, resourceType);
+        assertFalse(hasResource);
+        manager.close();
+    }
+
+    @Test
+    public void pluginNotSpecifiedWithMultipleProviders() {
+        IResourceThrottler provider1 = new FirstTestResourceThrottler();
+        IResourceThrottler provider2 = new SecondTestResourceThrottler();
+
+        when(pluginManager.getExtensions(IResourceThrottler.class)).thenReturn(
+            Arrays.asList(provider1, provider2));
+        manager = new ResourceThrottlerManager(null, pluginManager);
+
+        boolean hasResource = manager.hasResource(tenantId, resourceType);
+        // Should use one of the providers (order depends on TreeMap keySet 
iteration)
+        assertTrue(hasResource);
+        manager.close();
+    }
+
+    @Test
+    public void pluginNotSpecifiedWithMultipleSortByKeyProviders() {
+        IResourceThrottler provider1 = new FirstTestResourceThrottler();
+        IResourceThrottler provider2 = new SecondTestResourceThrottler();
+
+        when(pluginManager.getExtensions(IResourceThrottler.class)).thenReturn(
+            Arrays.asList(provider2, provider1));
+        manager = new ResourceThrottlerManager(null, pluginManager);
+
+        boolean hasResource = manager.hasResource(tenantId, resourceType);
+        // Should use one of the providers (order depends on TreeMap keySet 
iteration)
+        assertTrue(hasResource);
+        manager.close();
+    }
+
+    @Test(expectedExceptions = ResourceThrottlerException.class)
+    public void pluginNotFound() {
+        manager = new ResourceThrottlerManager("Fake", pluginManager);
+        manager.close();
+    }
+
+    @Test
+    public void hasResourceOK() {
+        manager = new 
ResourceThrottlerManager(mockResourceThrottler.getClass().getName(), 
pluginManager);
+        when(mockResourceThrottler.hasResource(anyString(), 
any(TenantResourceType.class)))
+            .thenReturn(true);
+        boolean hasResource = manager.hasResource(tenantId, resourceType);
+        assertTrue(hasResource);
+        assertEquals(meterRegistry.find("call.exec.timer")
+            .tag("method", "ResourceThrottler/hasResource")
+            .timer()
+            .count(), 1);
+        assertEquals(meterRegistry.find("call.exec.fail.count")
+            .tag("method", "ResourceThrottler/hasResource")
+            .counter()
+            .count(), 0);
+        manager.close();
+    }
+
+    @Test
+    public void hasResourceReturnsFalse() {
+        manager = new 
ResourceThrottlerManager(mockResourceThrottler.getClass().getName(), 
pluginManager);
+        when(mockResourceThrottler.hasResource(anyString(), 
any(TenantResourceType.class)))
+            .thenReturn(false);
+        boolean hasResource = manager.hasResource(tenantId, resourceType);
+        assertFalse(hasResource);
+        assertEquals(meterRegistry.find("call.exec.timer")
+            .tag("method", "ResourceThrottler/hasResource")
+            .timer()
+            .count(), 1);
+        assertEquals(meterRegistry.find("call.exec.fail.count")
+            .tag("method", "ResourceThrottler/hasResource")
+            .counter()
+            .count(), 0);
+        manager.close();
+    }
+
+    @Test
+    public void hasResourceThrowsException() {
+        manager = new 
ResourceThrottlerManager(mockResourceThrottler.getClass().getName(), 
pluginManager);
+        when(mockResourceThrottler.hasResource(anyString(), 
any(TenantResourceType.class)))
+            .thenThrow(new RuntimeException("Intend Error"));
+        boolean hasResource = manager.hasResource(tenantId, resourceType);
+        // Should return true when exception occurs (fail-safe)
+        assertTrue(hasResource);
+        assertEquals(meterRegistry.find("call.exec.timer")
+            .tag("method", "ResourceThrottler/hasResource")
+            .timer()
+            .count(), 0);
+        assertEquals(meterRegistry.find("call.exec.fail.count")
+            .tag("method", "ResourceThrottler/hasResource")
+            .counter()
+            .count(), 1);
+        manager.close();
+    }
+
+    @Test
+    public void close() {
+        manager = new 
ResourceThrottlerManager(mockResourceThrottler.getClass().getName(), 
pluginManager);
+        manager.close();
+        // Should be idempotent
+        manager.close();
+    }
+
+    @Test
+    public void testAllResourceTypes() {
+        
when(pluginManager.getExtensions(IResourceThrottler.class)).thenReturn(Collections.emptyList());
+        manager = new ResourceThrottlerManager(null, pluginManager);
+        // Test all resource types in dev only mode
+        for (TenantResourceType type : TenantResourceType.values()) {
+            assertTrue(manager.hasResource(tenantId, type));
+        }
+        manager.close();
+    }
+
+    static class FirstTestResourceThrottler implements IResourceThrottler {
+        @Override
+        public boolean hasResource(String tenantId, TenantResourceType type) {
+            return true;
+        }
+    }
+
+    static class SecondTestResourceThrottler implements IResourceThrottler {
+        @Override
+        public boolean hasResource(String tenantId, TenantResourceType type) {
+            return false;
+        }
+    }
+}
diff --git 
a/bifromq-plugin/bifromq-plugin-resource-throttler-helper/src/test/java/org/apache/bifromq/plugin/resourcethrottler/TenantResourceThrottlerManagerTest.java
 
b/bifromq-plugin/bifromq-plugin-resource-throttler-helper/src/test/java/org/apache/bifromq/plugin/resourcethrottler/TenantResourceThrottlerManagerTest.java
index 72c175587..b7aa2b5ad 100644
--- 
a/bifromq-plugin/bifromq-plugin-resource-throttler-helper/src/test/java/org/apache/bifromq/plugin/resourcethrottler/TenantResourceThrottlerManagerTest.java
+++ 
b/bifromq-plugin/bifromq-plugin-resource-throttler-helper/src/test/java/org/apache/bifromq/plugin/resourcethrottler/TenantResourceThrottlerManagerTest.java
@@ -65,13 +65,10 @@ public class TenantResourceThrottlerManagerTest {
         manager.close();
     }
 
-    @Test
+    @Test(expectedExceptions = ResourceThrottlerException.class)
     public void pluginNotFound() {
         ResourceThrottlerManager devOnlyManager = new 
ResourceThrottlerManager(null, pluginManager);
         manager = new ResourceThrottlerManager("Fake", pluginManager);
-        for (TenantResourceType type : TenantResourceType.values()) {
-            assertEquals(devOnlyManager.hasResource(tenantId, type), 
manager.hasResource(tenantId, type));
-        }
         devOnlyManager.close();
     }
 
diff --git 
a/bifromq-plugin/bifromq-plugin-setting-provider-helper/src/main/java/org/apache/bifromq/plugin/settingprovider/SettingProviderException.java
 
b/bifromq-plugin/bifromq-plugin-setting-provider-helper/src/main/java/org/apache/bifromq/plugin/settingprovider/SettingProviderException.java
new file mode 100644
index 000000000..9496b299d
--- /dev/null
+++ 
b/bifromq-plugin/bifromq-plugin-setting-provider-helper/src/main/java/org/apache/bifromq/plugin/settingprovider/SettingProviderException.java
@@ -0,0 +1,31 @@
+/*
+ * 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.bifromq.plugin.settingprovider;
+
+import org.pf4j.util.StringUtils;
+
+public class SettingProviderException extends RuntimeException {
+
+    public SettingProviderException(String message, Object... args) {
+        super(StringUtils.format(message, args));
+    }
+
+}
diff --git 
a/bifromq-plugin/bifromq-plugin-setting-provider-helper/src/main/java/org/apache/bifromq/plugin/settingprovider/SettingProviderManager.java
 
b/bifromq-plugin/bifromq-plugin-setting-provider-helper/src/main/java/org/apache/bifromq/plugin/settingprovider/SettingProviderManager.java
index 7ecb8bb1b..b1e2f1e48 100644
--- 
a/bifromq-plugin/bifromq-plugin-setting-provider-helper/src/main/java/org/apache/bifromq/plugin/settingprovider/SettingProviderManager.java
+++ 
b/bifromq-plugin/bifromq-plugin-setting-provider-helper/src/main/java/org/apache/bifromq/plugin/settingprovider/SettingProviderManager.java
@@ -20,6 +20,7 @@
 package org.apache.bifromq.plugin.settingprovider;
 
 import java.util.Map;
+import java.util.TreeMap;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.stream.Collectors;
 import lombok.extern.slf4j.Slf4j;
@@ -35,18 +36,28 @@ public class SettingProviderManager implements 
ISettingProvider, AutoCloseable {
 
     public SettingProviderManager(String settingProviderFQN, PluginManager 
pluginMgr) {
         Map<String, ISettingProvider> availSettingProviders = 
pluginMgr.getExtensions(ISettingProvider.class).stream()
-            .collect(Collectors.toMap(e -> e.getClass().getName(), e -> e));
+            .collect(Collectors.toMap(e -> e.getClass().getName(), e -> e,
+                    (k,v) -> v, TreeMap::new));
         if (availSettingProviders.isEmpty()) {
             pluginLog.warn("No setting provider plugin available, use DEV ONLY 
one instead");
+
+
             provider = new MonitoredSettingProvider(new 
DevOnlySettingProvider());
         } else {
             if (settingProviderFQN == null) {
-                pluginLog.warn("Setting provider plugin type not specified, 
use DEV ONLY one instead");
-                provider = new MonitoredSettingProvider(new 
DevOnlySettingProvider());
+                if (availSettingProviders.size() > 1) {
+                    pluginLog.info("Setting provider plugin type not 
specified, use the first found");
+                }
+                String firstSettingProviderFQN = 
availSettingProviders.keySet().iterator().next();
+                pluginLog.info("Setting provider plugin loaded: {}", 
firstSettingProviderFQN);
+                provider = new CacheableSettingProvider(
+                        new 
MonitoredSettingProvider(availSettingProviders.get(firstSettingProviderFQN)),
+                        CacheOptions.DEFAULT);
             } else if (!availSettingProviders.containsKey(settingProviderFQN)) 
{
-                pluginLog.warn("Setting provider plugin type '{}' not found, 
use DEV ONLY one instead",
+                pluginLog.warn("Setting provider plugin type '{}' not found, 
so the system will shut down.",
                     settingProviderFQN);
-                provider = new MonitoredSettingProvider(new 
DevOnlySettingProvider());
+                throw new SettingProviderException("Setting provider plugin 
type '%s' not found, so the system will shut down.",
+                        settingProviderFQN);
             } else {
                 pluginLog.info("Setting provider plugin type: {}", 
settingProviderFQN);
                 provider = new CacheableSettingProvider(
diff --git 
a/bifromq-plugin/bifromq-plugin-setting-provider-helper/src/test/java/org/apache/bifromq/plugin/settingprovider/SettingProviderManagerTest.java
 
b/bifromq-plugin/bifromq-plugin-setting-provider-helper/src/test/java/org/apache/bifromq/plugin/settingprovider/SettingProviderManagerTest.java
index 6807f05d1..32c03cf5e 100644
--- 
a/bifromq-plugin/bifromq-plugin-setting-provider-helper/src/test/java/org/apache/bifromq/plugin/settingprovider/SettingProviderManagerTest.java
+++ 
b/bifromq-plugin/bifromq-plugin-setting-provider-helper/src/test/java/org/apache/bifromq/plugin/settingprovider/SettingProviderManagerTest.java
@@ -14,68 +14,132 @@
  * "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.    
+ * under the License.
  */
 
 package org.apache.bifromq.plugin.settingprovider;
 
 import static org.awaitility.Awaitility.await;
+import static org.mockito.Mockito.when;
 import static org.testng.Assert.assertEquals;
 
-import org.pf4j.DefaultPluginManager;
+import java.util.Arrays;
+import java.util.Collections;
+import lombok.extern.slf4j.Slf4j;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
 import org.pf4j.PluginManager;
 import org.testng.annotations.AfterMethod;
 import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
 
+@Slf4j
 public class SettingProviderManagerTest {
-    private final String tenantId = "tenantA";
-    private SettingProviderManager manager;
+    private static final String TENANT_ID = "tenantA";
+    @Mock
     private PluginManager pluginManager;
+    @Mock
+    private ISettingProvider mockProvider;
+    private SettingProviderManager manager;
+    private AutoCloseable closeable;
 
     @BeforeMethod
     public void setup() {
         // to speed up tests
         
System.setProperty(CacheOptions.SettingCacheOptions.SYS_PROP_SETTING_REFRESH_SECONDS,
 "1");
-        pluginManager = new DefaultPluginManager();
-        pluginManager.loadPlugins();
-        pluginManager.startPlugins();
+        closeable = MockitoAnnotations.openMocks(this);
+        when(pluginManager.getExtensions(ISettingProvider.class)).thenReturn(
+            Collections.singletonList(mockProvider));
     }
 
     @AfterMethod
-    public void teardown() {
-        pluginManager.stopPlugins();
-        pluginManager.unloadPlugins();
+    public void tearDown() throws Exception {
+        if (manager != null) {
+            manager.close();
+        }
+        closeable.close();
+        
System.clearProperty(CacheOptions.SettingCacheOptions.SYS_PROP_SETTING_REFRESH_SECONDS);
     }
 
     @Test
     public void devOnlyMode() {
+        
when(pluginManager.getExtensions(ISettingProvider.class)).thenReturn(Collections.emptyList());
         manager = new SettingProviderManager(null, pluginManager);
-        manager.provide(Setting.DebugModeEnabled, tenantId);
-        manager.close();
+        for (Setting setting : Setting.values()) {
+            assertEquals(manager.provide(setting, TENANT_ID), (Object) 
setting.initialValue());
+        }
     }
 
     @Test
     public void pluginSpecified() {
-        manager = new 
SettingProviderManager(SettingProviderTestStub.class.getName(), pluginManager);
-        await().until(() -> (int) manager.provide(Setting.MaxTopicLevels, 
tenantId) == 64);
-        manager.close();
+        when(mockProvider.provide(Setting.MaxTopicLevels, 
TENANT_ID)).thenReturn(64);
+        manager = new 
SettingProviderManager(mockProvider.getClass().getName(), pluginManager);
+        await().until(() -> (int) manager.provide(Setting.MaxTopicLevels, 
TENANT_ID) == 64);
+    }
+
+    @Test
+    public void pluginNotSpecifiedWithSingleProvider() {
+        when(mockProvider.provide(Setting.MaxTopicLevels, 
TENANT_ID)).thenReturn(32);
+        manager = new SettingProviderManager(null, pluginManager);
+        await().until(() -> (int) manager.provide(Setting.MaxTopicLevels, 
TENANT_ID) == 32);
     }
 
     @Test
+    public void pluginNotSpecifiedWithMultipleProviders() {
+        ISettingProvider provider1 = new FirstTestSettingProvider();
+        ISettingProvider provider2 = new SecondTestSettingProvider();
+
+        when(pluginManager.getExtensions(ISettingProvider.class)).thenReturn(
+            Arrays.asList(provider1, provider2));
+        manager = new SettingProviderManager(null, pluginManager);
+
+        await().until(() -> (int) manager.provide(Setting.MaxTopicLevels, 
TENANT_ID) == 100);
+    }
+
+    @Test
+    public void pluginNotSpecifiedWithMultipleSortByKeyProviders() {
+        ISettingProvider provider1 = new FirstTestSettingProvider();
+        ISettingProvider provider2 = new SecondTestSettingProvider();
+
+        when(pluginManager.getExtensions(ISettingProvider.class)).thenReturn(
+            Arrays.asList(provider2, provider1));
+        manager = new SettingProviderManager(null, pluginManager);
+
+        await().until(() -> (int) manager.provide(Setting.MaxTopicLevels, 
TENANT_ID) == 100);
+    }
+
+    @Test(expectedExceptions = SettingProviderException.class)
     public void pluginNotFound() {
-        SettingProviderManager devOnlyManager = new 
SettingProviderManager(null, pluginManager);
         manager = new SettingProviderManager("Fake", pluginManager);
-        for (Setting setting : Setting.values()) {
-            assertEquals(devOnlyManager.provide(setting, tenantId),
-                (Object) manager.provide(setting, tenantId));
-        }
-        devOnlyManager.close();
+        manager.close();
     }
 
     @Test
     public void stop() {
-        manager = new 
SettingProviderManager(SettingProviderTestStub.class.getName(), pluginManager);
+        manager = new 
SettingProviderManager(mockProvider.getClass().getName(), pluginManager);
         manager.close();
+        manager = null;
+    }
+
+    static class FirstTestSettingProvider implements ISettingProvider {
+        @SuppressWarnings("unchecked")
+        @Override
+        public <R> R provide(Setting setting, String tenantId) {
+            if (setting == Setting.MaxTopicLevels) {
+                return (R) Integer.valueOf(100);
+            }
+            return setting.initialValue();
+        }
+    }
+
+    static class SecondTestSettingProvider implements ISettingProvider {
+        @SuppressWarnings("unchecked")
+        @Override
+        public <R> R provide(Setting setting, String tenantId) {
+            if (setting == Setting.MaxTopicLevels) {
+                return (R) Integer.valueOf(200);
+            }
+            return setting.initialValue();
+        }
     }
 }


Reply via email to