Repository: PHP-CS-Fixer/PHP-CS-Fixer (php)

Issue:
Add `maxProcesses` option to `ParallelConfigFactory::detect()`
### Feature request

Hey.

We're using `ParallelConfigFactory::detect()` as it generally fits our needs well, aside from one issue: on some of our CI servers we have very high core counts (>100), and parallel detection detects them all and runs 100+ processes. Sure it's quick, but I'm sure dropping it down to 20 would be almost as fast, but without all the overhead. 

So it would be nice to have an option in the detect method to "clamp" the processes count to a specified maximum - e.g.:

```php
// Given there are 20 cores detected

ParallelConfigFactory::detect(); // uses 20 cores
ParallelConfigFactory::detect(maxProcesses: 30); // uses 20 cores
ParallelConfigFactory::detect(maxProcesses: 10); // clamps to 10 cores
```

I will PR the change :)

### Link to Discussion where feature request was formed

_No response_

### Contribution Checks

- [ ] I have verified if this feature request was already discussed
- [X] I am familiar with ["Feature or bug?"](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/feature-or-bug.rst)

Pull request:
feat: Ability to set upper limit when using CPU auto-detection
Closes #8279 

Feel free to make edits on the branch if you want :)

Patch:
diff --git a/composer.json b/composer.json
index 104d8683c..588723683 100644
--- a/composer.json
+++ b/composer.json
@@ -27,7 +27,7 @@
         "clue/ndjson-react": "^1.0",
         "composer/semver": "^3.4",
         "composer/xdebug-handler": "^3.0.3",
-        "fidry/cpu-core-counter": "^1.0",
+        "fidry/cpu-core-counter": "^1.2",
         "react/child-process": "^0.6.5",
         "react/event-loop": "^1.0",
         "react/promise": "^2.0 || ^3.0",
diff --git a/src/Runner/Parallel/ParallelConfigFactory.php b/src/Runner/Parallel/ParallelConfigFactory.php
index 162cf4374..5d306d451 100644
--- a/src/Runner/Parallel/ParallelConfigFactory.php
+++ b/src/Runner/Parallel/ParallelConfigFactory.php
@@ -35,10 +35,12 @@ public static function sequential(): ParallelConfig
     /**
      * @param null|positive-int $filesPerProcess
      * @param null|positive-int $processTimeout
+     * @param null|positive-int $maxProcesses
      */
     public static function detect(
         ?int $filesPerProcess = null,
-        ?int $processTimeout = null
+        ?int $processTimeout = null,
+        ?int $maxProcesses = null
     ): ParallelConfig {
         if (null === self::$cpuDetector) {
             self::$cpuDetector = new CpuCoreCounter([
@@ -47,8 +49,11 @@ public static function detect(
             ]);
         }
 
+        // Reserve 1 core for the main orchestrating process
+        $available = self::$cpuDetector->getAvailableForParallelisation(1, $maxProcesses);
+
         return new ParallelConfig(
-            self::$cpuDetector->getCount(),
+            $available->availableCpus,
             $filesPerProcess ?? ParallelConfig::DEFAULT_FILES_PER_PROCESS,
             $processTimeout ?? ParallelConfig::DEFAULT_PROCESS_TIMEOUT
         );


Test patch:
diff --git a/tests/Runner/Parallel/ParallelConfigFactoryTest.php b/tests/Runner/Parallel/ParallelConfigFactoryTest.php
index 6319bb21f..77e1d1df4 100644
--- a/tests/Runner/Parallel/ParallelConfigFactoryTest.php
+++ b/tests/Runner/Parallel/ParallelConfigFactoryTest.php
@@ -27,6 +27,13 @@
  */
 final class ParallelConfigFactoryTest extends TestCase
 {
+    protected function tearDown(): void
+    {
+        $this->mockCpuCount(null);
+
+        parent::tearDown();
+    }
+
     public function testSequentialConfigHasExactlyOneProcess(): void
     {
         $config = ParallelConfigFactory::sequential();
@@ -39,29 +46,32 @@ public function testSequentialConfigHasExactlyOneProcess(): void
      */
     public function testDetectConfigurationWithoutParams(): void
     {
-        \Closure::bind(static function (): void {
-            ParallelConfigFactory::$cpuDetector = new CpuCoreCounter([
-                new DummyCpuCoreFinder(7),
-            ]);
-        }, null, ParallelConfigFactory::class)();
+        $this->mockCpuCount(7);
 
         $config = ParallelConfigFactory::detect();
 
-        self::assertSame(7, $config->getMaxProcesses());
+        self::assertSame(6, $config->getMaxProcesses());
         self::assertSame(ParallelConfig::DEFAULT_FILES_PER_PROCESS, $config->getFilesPerProcess());
         self::assertSame(ParallelConfig::DEFAULT_PROCESS_TIMEOUT, $config->getProcessTimeout());
-
-        \Closure::bind(static function (): void {
-            ParallelConfigFactory::$cpuDetector = null;
-        }, null, ParallelConfigFactory::class)();
     }
 
     public function testDetectConfigurationWithParams(): void
     {
-        $config = ParallelConfigFactory::detect(22, 2_200);
+        $this->mockCpuCount(7);
+
+        $config1 = ParallelConfigFactory::detect(22, 2_200, 5);
+
+        self::assertSame(5, $config1->getMaxProcesses());
+        self::assertSame(22, $config1->getFilesPerProcess());
+        self::assertSame(2_200, $config1->getProcessTimeout());
+
+        $config2 = ParallelConfigFactory::detect(22, 2_200, 6);
+
+        self::assertSame(6, $config2->getMaxProcesses());
 
-        self::assertSame(22, $config->getFilesPerProcess());
-        self::assertSame(2_200, $config->getProcessTimeout());
+        $config3 = ParallelConfigFactory::detect(22, 2_200, 10);
+
+        self::assertSame(6, $config3->getMaxProcesses());
     }
 
     public function testDetectConfigurationWithDefaultValue(): void
@@ -77,6 +87,8 @@ public function testDetectConfigurationWithDefaultValue(): void
      */
     public function testDetectConfigurationWithNamedArgs(): void
     {
+        $this->mockCpuCount(7);
+
         // First argument omitted, second one provided via named argument
         $config1 = \call_user_func_array([ParallelConfigFactory::class, 'detect'], ['processTimeout' => 300]);
 
@@ -85,10 +97,12 @@ public function testDetectConfigurationWithNamedArgs(): void
 
         // Flipped order of arguments using named arguments syntax
         $config2 = \call_user_func_array([ParallelConfigFactory::class, 'detect'], [
+            'maxProcesses' => 1,
             'processTimeout' => 300,
             'filesPerProcess' => 5,
         ]);
 
+        self::assertSame(1, $config2->getMaxProcesses());
         self::assertSame(5, $config2->getFilesPerProcess());
         self::assertSame(300, $config2->getProcessTimeout());
 
@@ -97,5 +111,25 @@ public function testDetectConfigurationWithNamedArgs(): void
 
         self::assertSame(7, $config3->getFilesPerProcess());
         self::assertSame(ParallelConfig::DEFAULT_PROCESS_TIMEOUT, $config3->getProcessTimeout());
+
+        // Only third argument provided, but via named argument
+        $config3 = \call_user_func_array([ParallelConfigFactory::class, 'detect'], ['maxProcesses' => 1]);
+
+        self::assertSame(1, $config3->getMaxProcesses());
+        self::assertSame(ParallelConfig::DEFAULT_FILES_PER_PROCESS, $config3->getFilesPerProcess());
+        self::assertSame(ParallelConfig::DEFAULT_PROCESS_TIMEOUT, $config3->getProcessTimeout());
+    }
+
+    /**
+     * @param null|positive-int $count
+     */
+    private function mockCpuCount(?int $count): void
+    {
+        \Closure::bind(static function () use ($count): void {
+            ParallelConfigFactory::$cpuDetector = null !== $count ?
+                new CpuCoreCounter([
+                    new DummyCpuCoreFinder($count),
+                ]) : null;
+        }, null, ParallelConfigFactory::class)();
     }
 }