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

nvazquez pushed a commit to branch 4.22
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to refs/heads/4.22 by this push:
     new 2974af84f22 Make worker and API thread pool size configurable (#13565)
2974af84f22 is described below

commit 2974af84f22a8def8339380628d8653a4f016d3f
Author: Daman Arora <[email protected]>
AuthorDate: Tue Oct 6 15:21:42 2026 -0400

    Make worker and API thread pool size configurable (#13565)
    
    * Make worker and API thread pool size configurable
    
    The AsyncJobManager's API and Worker executor thread pool sizes are
    currently derived solely from db.cloud.maxActive (maxActive/2 and
    maxActive*2/3 respectively). This coupling doesn't account for
    environments with predominantly I/O-bound workers, where threads are
    held waiting on external responses for extended periods and higher
    pool sizes may be needed independently of the DB connection count.
    
    This adds two global config keys:
    - api.job.pool.size (default: 50)
    - work.job.pool.size (default: 50)
    
    The actual pool size is Math.max(configured value, db-derived
    default), preserving existing behavior by default while allowing
    operators to increase pool sizes when workloads require it. Pool
    sizes and their derivation are logged at startup for observability.
    
    * Replace synthetic pool size tests with real configure() executor 
assertions
    
    The previous unit tests for the api.job.pool.size/work.job.pool.size
    config keys only re-ran Math.max() on local hardcoded values, or
    checked ConfigKey plumbing directly, without ever calling
    AsyncJobManagerImpl.configure() itself. That meant a regression in the
    actual pool-sizing logic wouldn't have been caught.
    
    These tests now invoke configure() directly and assert on the real
    core pool size of the created _apiJobExecutor/_workerJobExecutor via
    reflection, with @Before/@After hooks to stub db.properties and reset
    config/executor state between tests.
    
    ---------
    
    Co-authored-by: Sachin R <[email protected]>
    Co-authored-by: mprokopchuk <[email protected]>
---
 .../framework/jobs/impl/AsyncJobManagerImpl.java   |  19 ++-
 .../jobs/impl/AsyncJobManagerImplTest.java         | 146 +++++++++++++++++++++
 2 files changed, 160 insertions(+), 5 deletions(-)

diff --git 
a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImpl.java
 
b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImpl.java
index 1cb1cb4e309..1572adc5008 100644
--- 
a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImpl.java
+++ 
b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImpl.java
@@ -125,6 +125,10 @@ public class AsyncJobManagerImpl extends ManagerBase 
implements AsyncJobManager,
             Integer.class, "vm.job.lock.timeout", "1800",
             "Time in seconds to wait in acquiring lock to submit a vm worker 
job", false);
     private static final ConfigKey<Boolean> HidePassword = new 
ConfigKey<Boolean>("Advanced", Boolean.class, "log.hide.password", "true", "If 
set to true, the password is hidden", true, ConfigKey.Scope.Global);
+    public static final ConfigKey<Integer> ApiJobPoolSize = new 
ConfigKey<>("Advanced", Integer.class, "api.job.pool.size", "50",
+        "Minimum size of the API job executor thread pool. Actual size is the 
max of this value and db.cloud.maxActive / 2.", false, ConfigKey.Scope.Global);
+    public static final ConfigKey<Integer> WorkJobPoolSize = new 
ConfigKey<>("Advanced", Integer.class, "work.job.pool.size", "50",
+        "Minimum size of the Worker job executor thread pool. Actual size is 
the max of this value and db.cloud.maxActive * 2 / 3.", false, 
ConfigKey.Scope.Global);
 
 
     private static final int ACQUIRE_GLOBAL_LOCK_TIMEOUT_FOR_COOPERATION = 3;  
   // 3 seconds
@@ -198,7 +202,7 @@ public class AsyncJobManagerImpl extends ManagerBase 
implements AsyncJobManager,
 
     @Override
     public ConfigKey<?>[] getConfigKeys() {
-        return new ConfigKey<?>[] {JobExpireMinutes, 
JobCancelThresholdMinutes, VmJobLockTimeout, HidePassword};
+        return new ConfigKey<?>[] {JobExpireMinutes, 
JobCancelThresholdMinutes, VmJobLockTimeout, HidePassword, ApiJobPoolSize, 
WorkJobPoolSize};
     }
 
     @Override
@@ -1100,13 +1104,18 @@ public class AsyncJobManagerImpl extends ManagerBase 
implements AsyncJobManager,
             final Properties dbProps = DbProperties.getDbProperties();
             final int cloudMaxActive = 
Integer.parseInt(dbProps.getProperty("db.cloud.maxActive"));
 
-            int apiPoolSize = cloudMaxActive / 2;
-            int workPoolSize = (cloudMaxActive * 2) / 3;
+            int defaultApiPoolSize = cloudMaxActive / 2;
+            int defaultWorkPoolSize = (cloudMaxActive * 2) / 3;
 
-            logger.info("Start AsyncJobManager API executor thread pool in 
size " + apiPoolSize);
+            int apiPoolSize = Math.max(ApiJobPoolSize.value(), 
defaultApiPoolSize);
+            int workPoolSize = Math.max(WorkJobPoolSize.value(), 
defaultWorkPoolSize);
+
+            logger.info("Start AsyncJobManager API executor thread pool in 
size " + apiPoolSize +
+                " (configured=" + ApiJobPoolSize.value() + ", db.derived=" + 
defaultApiPoolSize + ", db.cloud.maxActive=" + cloudMaxActive + ")");
             _apiJobExecutor = Executors.newFixedThreadPool(apiPoolSize, new 
NamedThreadFactory(AsyncJobManager.API_JOB_POOL_THREAD_PREFIX));
 
-            logger.info("Start AsyncJobManager Work executor thread pool in 
size " + workPoolSize);
+            logger.info("Start AsyncJobManager Work executor thread pool in 
size " + workPoolSize +
+                " (configured=" + WorkJobPoolSize.value() + ", db.derived=" + 
defaultWorkPoolSize + ", db.cloud.maxActive=" + cloudMaxActive + ")");
             _workerJobExecutor = Executors.newFixedThreadPool(workPoolSize, 
new NamedThreadFactory(AsyncJobManager.WORK_JOB_POOL_THREAD_PREFIX));
         } catch (final Exception e) {
             throw new ConfigurationException("Unable to load db.properties to 
configure AsyncJobManagerImpl");
diff --git 
a/framework/jobs/src/test/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImplTest.java
 
b/framework/jobs/src/test/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImplTest.java
index 0be5dbc01cb..08d4c2afd43 100644
--- 
a/framework/jobs/src/test/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImplTest.java
+++ 
b/framework/jobs/src/test/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImplTest.java
@@ -17,19 +17,30 @@
 
 package org.apache.cloudstack.framework.jobs.impl;
 
+import java.lang.reflect.Field;
+import java.util.Properties;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ThreadPoolExecutor;
+
 import com.cloud.network.Network;
 import com.cloud.network.dao.NetworkDao;
 import com.cloud.network.dao.NetworkVO;
 import com.cloud.storage.Volume;
+import com.cloud.utils.db.DbProperties;
 import com.cloud.utils.fsm.NoTransitionException;
 import com.cloud.vm.VMInstanceVO;
 import com.cloud.vm.VirtualMachine;
 import com.cloud.vm.VirtualMachineManager;
 import com.cloud.vm.dao.VMInstanceDao;
+
 import org.apache.cloudstack.api.ApiCommandResourceType;
 import 
org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationService;
 import org.apache.cloudstack.engine.subsystem.api.storage.VolumeDataFactory;
 import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo;
+import org.apache.cloudstack.framework.config.ConfigKey;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.InjectMocks;
@@ -42,6 +53,12 @@ import static org.mockito.Mockito.when;
 
 @RunWith(MockitoJUnitRunner.class)
 public class AsyncJobManagerImplTest {
+
+    // db.cloud.maxActive value preloaded into DbProperties for every test in 
this class.
+    // Chosen so the db-derived defaults (4000/2 = 2000 for api, 4000*2/3 = 
2666 for worker)
+    // are non-trivial and easy to reason about vs. the configured minimums 
under test.
+    private static final int DB_CLOUD_MAX_ACTIVE = 4000;
+
     @Spy
     @InjectMocks
     AsyncJobManagerImpl asyncJobManager;
@@ -56,6 +73,30 @@ public class AsyncJobManagerImplTest {
     @Mock
     NetworkOrchestrationService networkOrchestrationService;
 
+    @Before
+    public void preloadDbProperties() throws Exception {
+        Properties props = new Properties();
+        props.setProperty("db.cloud.maxActive", 
String.valueOf(DB_CLOUD_MAX_ACTIVE));
+        // Bypass DbProperties' internal "loaded" guard so configure() reads 
our
+        // properties instead of trying to load a real db.properties file.
+        setDbPropertiesStatics(props, true);
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        try {
+            shutdownIfPresent("_apiJobExecutor");
+            shutdownIfPresent("_workerJobExecutor");
+        } finally {
+            // Reset static state even if executor shutdown throws, so we do 
not
+            // pollute later test classes with our stubbed ConfigKey values or
+            // preloaded DbProperties.
+            resetConfigValue(AsyncJobManagerImpl.ApiJobPoolSize);
+            resetConfigValue(AsyncJobManagerImpl.WorkJobPoolSize);
+            setDbPropertiesStatics(new Properties(), false);
+        }
+    }
+
     @Test
     public void testCleanupVolumeResource() {
         AsyncJobVO job = new AsyncJobVO();
@@ -93,4 +134,109 @@ public class AsyncJobManagerImplTest {
         Mockito.verify(networkOrchestrationService, 
Mockito.times(1)).stateTransitTo(networkVO,
                 Network.Event.OperationFailed);
     }
+
+    @Test
+    public void configureApiPoolUsesConfiguredValueWhenLargerThanDbDerived() 
throws Exception {
+        overrideConfigValue(AsyncJobManagerImpl.ApiJobPoolSize, 3000);
+        overrideConfigValue(AsyncJobManagerImpl.WorkJobPoolSize, 50);
+        invokeConfigureAndSwallowPostSetupNpe();
+        // apiPoolSize = max(3000, 4000/2) = 3000 (configured wins)
+        Assert.assertEquals(3000, apiPoolCoreSize());
+    }
+
+    @Test
+    public void configureApiPoolUsesDbDerivedWhenLargerThanConfigured() throws 
Exception {
+        overrideConfigValue(AsyncJobManagerImpl.ApiJobPoolSize, 50);
+        overrideConfigValue(AsyncJobManagerImpl.WorkJobPoolSize, 50);
+        invokeConfigureAndSwallowPostSetupNpe();
+        // apiPoolSize = max(50, 4000/2) = 2000 (db-derived wins)
+        Assert.assertEquals(DB_CLOUD_MAX_ACTIVE / 2, apiPoolCoreSize());
+    }
+
+    @Test
+    public void configureWorkerPoolUsesTwoThirdsFormula() throws Exception {
+        overrideConfigValue(AsyncJobManagerImpl.ApiJobPoolSize, 50);
+        overrideConfigValue(AsyncJobManagerImpl.WorkJobPoolSize, 50);
+        invokeConfigureAndSwallowPostSetupNpe();
+        // workPoolSize = max(50, 4000*2/3) = 2666 (db-derived wins)
+        Assert.assertEquals((DB_CLOUD_MAX_ACTIVE * 2) / 3, 
workerPoolCoreSize());
+    }
+
+    @Test
+    public void 
configureWorkerPoolUsesConfiguredValueWhenLargerThanDbDerived() throws 
Exception {
+        overrideConfigValue(AsyncJobManagerImpl.ApiJobPoolSize, 50);
+        overrideConfigValue(AsyncJobManagerImpl.WorkJobPoolSize, 5000);
+        invokeConfigureAndSwallowPostSetupNpe();
+        // workPoolSize = max(5000, 4000*2/3) = 5000 (configured wins)
+        Assert.assertEquals(5000, workerPoolCoreSize());
+    }
+
+    /**
+     * Invoke configure() and swallow the NPE it throws when wiring 
SearchBuilders
+     * on the un-mocked DAOs after the pool-sizing block. The executors are 
created
+     * inside configure()'s try/catch (before the DAO wiring), so pool sizes 
can
+     * still be inspected after the failure.
+     */
+    private void invokeConfigureAndSwallowPostSetupNpe() {
+        try {
+            asyncJobManager.configure(null, null);
+        } catch (Exception ignored) {
+            // expected: DAO wiring after pool-sizing NPEs because we 
intentionally
+            // did not mock the DAOs (they are not part of what we are 
testing).
+        }
+    }
+
+    private int apiPoolCoreSize() throws Exception {
+        Object executor = readField("_apiJobExecutor");
+        Assert.assertNotNull("configure() did not create _apiJobExecutor - has 
DAO wiring been moved before pool sizing?", executor);
+        return ((ThreadPoolExecutor) executor).getCorePoolSize();
+    }
+
+    private int workerPoolCoreSize() throws Exception {
+        Object executor = readField("_workerJobExecutor");
+        Assert.assertNotNull("configure() did not create _workerJobExecutor - 
has DAO wiring been moved before pool sizing?", executor);
+        return ((ThreadPoolExecutor) executor).getCorePoolSize();
+    }
+
+    private Object readField(String name) throws Exception {
+        Field f = AsyncJobManagerImpl.class.getDeclaredField(name);
+        f.setAccessible(true);
+        return f.get(asyncJobManager);
+    }
+
+    private void shutdownIfPresent(String executorFieldName) {
+        try {
+            Object executor = readField(executorFieldName);
+            if (executor instanceof ExecutorService) {
+                ((ExecutorService) executor).shutdownNow();
+            }
+        } catch (Exception ignored) {
+            // never populated by this test - nothing to clean up
+        }
+    }
+
+    private void overrideConfigValue(final ConfigKey<?> configKey, final 
Object value) {
+        try {
+            Field f = ConfigKey.class.getDeclaredField("_value");
+            f.setAccessible(true);
+            f.set(configKey, value);
+        } catch (IllegalAccessException | NoSuchFieldException e) {
+            Assert.fail(e.getMessage());
+        }
+    }
+
+    private void resetConfigValue(final ConfigKey<?> configKey) throws 
Exception {
+        Field f = ConfigKey.class.getDeclaredField("_value");
+        f.setAccessible(true);
+        f.set(configKey, null);
+    }
+
+    private void setDbPropertiesStatics(Properties props, boolean loaded) 
throws Exception {
+        Field propsField = DbProperties.class.getDeclaredField("properties");
+        Field loadedField = DbProperties.class.getDeclaredField("loaded");
+        propsField.setAccessible(true);
+        loadedField.setAccessible(true);
+        propsField.set(null, props);
+        loadedField.set(null, loaded);
+    }
 }

Reply via email to