diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..a7c02ef --- /dev/null +++ b/.envrc @@ -0,0 +1,5 @@ +use nix --packages \ + gnumake \ + yamllint + +source_env_if_exists .envrc.local diff --git a/.github/workflows/inspection.yaml b/.github/workflows/inspection.yaml index b00d42e..1b0d070 100644 --- a/.github/workflows/inspection.yaml +++ b/.github/workflows/inspection.yaml @@ -27,17 +27,17 @@ jobs: image: "macos-14" steps: - name: "Checkout" - uses: "actions/checkout@v4.1.1" + uses: "actions/checkout@v4.1.7" - name: "Set up PHP" - uses: "shivammathur/setup-php@2.29.0" + uses: "shivammathur/setup-php@2.31.1" with: coverage: "xdebug" php-version: "${{ matrix.php-version }}" tools: "phive" - name: "Install Composer dependencies" - uses: "ramsey/composer-install@v2" + uses: "ramsey/composer-install@v3" - name: "Execute finders" run: "php bin/execute.php" diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 40fe590..b9009d5 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -18,17 +18,17 @@ jobs: - "8.3" steps: - name: "Checkout" - uses: "actions/checkout@v4.1.1" + uses: "actions/checkout@v4.1.7" - name: "Set up PHP" - uses: "shivammathur/setup-php@2.29.0" + uses: "shivammathur/setup-php@2.31.1" with: coverage: "none" php-version: "${{ matrix.php-version }}" tools: "phive" - name: "Install Composer dependencies" - uses: "ramsey/composer-install@v2" + uses: "ramsey/composer-install@v3" - name: "Install ComposerNormalize" run: "phive install composer-normalize --trust-gpg-keys C00543248C87FB13" @@ -54,17 +54,17 @@ jobs: - "8.3" steps: - name: "Checkout" - uses: "actions/checkout@v4.1.1" + uses: "actions/checkout@v4.1.7" - name: "Set up PHP" - uses: "shivammathur/setup-php@2.29.0" + uses: "shivammathur/setup-php@2.31.1" with: coverage: "none" php-version: "${{ matrix.php-version }}" tools: "phive" - name: "Install Composer dependencies" - uses: "ramsey/composer-install@v2" + uses: "ramsey/composer-install@v3" - name: "Run Auto-Review tests" run: "make autoreview" @@ -82,16 +82,16 @@ jobs: - "8.3" steps: - name: "Checkout" - uses: "actions/checkout@v4.1.1" + uses: "actions/checkout@v4.1.7" - name: "Set up PHP" - uses: "shivammathur/setup-php@2.29.0" + uses: "shivammathur/setup-php@2.31.1" with: coverage: "none" php-version: "${{ matrix.php-version }}" - name: "Install Composer dependencies" - uses: "ramsey/composer-install@v2" + uses: "ramsey/composer-install@v3" - name: "Run the PHPUnit tests" run: "make phpunit" @@ -106,7 +106,7 @@ jobs: - "7.3" steps: - name: "Checkout" - uses: "actions/checkout@v4.1.1" + uses: "actions/checkout@v4.1.7" - name: "Remove incompatible dev dependencies" run: | @@ -115,13 +115,13 @@ jobs: composer remove --dev webmozarts/strict-phpunit --no-update --no-install; - name: "Set up PHP" - uses: "shivammathur/setup-php@2.29.0" + uses: "shivammathur/setup-php@2.31.1" with: coverage: "none" php-version: "${{ matrix.php-version }}" - name: "Install Composer dependencies" - uses: "ramsey/composer-install@v2" + uses: "ramsey/composer-install@v3" - name: "Run the PHPUnit tests" run: "vendor/bin/phpunit --configuration phpunit_legacy.xml.dist" @@ -135,17 +135,17 @@ jobs: - "8.3" steps: - name: "Checkout" - uses: "actions/checkout@v4.1.1" + uses: "actions/checkout@v4.1.7" - name: "Set up PHP" - uses: "shivammathur/setup-php@2.29.0" + uses: "shivammathur/setup-php@2.31.1" with: coverage: "xdebug" php-version: "${{ matrix.php-version }}" tools: "phive" - name: "Install Composer dependencies" - uses: "ramsey/composer-install@v2" + uses: "ramsey/composer-install@v3" - name: "Install Infection" run: "phive install infection --trust-gpg-keys C5095986493B4AA0" @@ -171,17 +171,17 @@ jobs: - "8.3" steps: - name: "Checkout" - uses: "actions/checkout@v4.1.1" + uses: "actions/checkout@v4.1.7" - name: "Set up PHP" - uses: "shivammathur/setup-php@2.29.0" + uses: "shivammathur/setup-php@2.31.1" with: coverage: "xdebug" php-version: "${{ matrix.php-version }}" tools: "phive" - name: "Install Composer dependencies" - uses: "ramsey/composer-install@v2" + uses: "ramsey/composer-install@v3" - name: "Setup the expected output" run: "mv e2e/expected-output-ubuntu e2e/expected-output" @@ -198,17 +198,17 @@ jobs: - "8.3" steps: - name: "Checkout" - uses: "actions/checkout@v4.1.1" + uses: "actions/checkout@v4.1.7" - name: "Set up PHP" - uses: "shivammathur/setup-php@2.29.0" + uses: "shivammathur/setup-php@2.31.1" with: coverage: "xdebug" php-version: "${{ matrix.php-version }}" tools: "phive" - name: "Install Composer dependencies" - uses: "ramsey/composer-install@v2" + uses: "ramsey/composer-install@v3" - name: "Setup the expected output" run: "mv e2e/expected-output-osx e2e/expected-output" @@ -225,17 +225,17 @@ jobs: - "8.3" steps: - name: "Checkout" - uses: "actions/checkout@v4.1.1" + uses: "actions/checkout@v4.1.7" - name: "Set up PHP" - uses: "shivammathur/setup-php@2.29.0" + uses: "shivammathur/setup-php@2.31.1" with: coverage: "xdebug" php-version: "${{ matrix.php-version }}" tools: "phive" - name: "Install Composer dependencies" - uses: "ramsey/composer-install@v2" + uses: "ramsey/composer-install@v3" - name: "Setup the expected output" run: "mv e2e/expected-output-windows e2e/expected-output" @@ -252,17 +252,17 @@ jobs: - "8.2" steps: - name: "Checkout" - uses: "actions/checkout@v4.1.1" + uses: "actions/checkout@v4.1.7" - name: "Set up PHP" - uses: "shivammathur/setup-php@2.29.0" + uses: "shivammathur/setup-php@2.31.1" with: coverage: "xdebug" php-version: "${{ matrix.php-version }}" tools: "phive" - name: "Install Composer dependencies" - uses: "ramsey/composer-install@v2" + uses: "ramsey/composer-install@v3" - name: "Setup the expected output" run: "mv e2e/expected-output-ubuntu-restricted e2e/expected-output" diff --git a/.gitignore b/.gitignore index 353ee84..4bc36e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ +.envrc.local /.build/ +/.direnv/ /composer.lock /e2e/actual-output /e2e/expected-output diff --git a/.phive/phars.xml b/.phive/phars.xml index e32840a..ceb6c74 100644 --- a/.phive/phars.xml +++ b/.phive/phars.xml @@ -1,6 +1,6 @@ - - - + + + diff --git a/README.md b/README.md index 4816935..6b554d2 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ use Fidry\CpuCoreCounter\Finder\DummyCpuCoreFinder; $counter = new CpuCoreCounter(); +// For knowing the number of cores you can use for launching parallel processes: +$counter->getAvailableForParallelisation()->availableCpus; + +// Get the number of CPU cores (by default it will use the logical cores count): try { $counter->getCount(); // e.g. 8 } catch (NumberOfCpuCoreNotFound) { @@ -29,6 +33,10 @@ $counter = new CpuCoreCounter([ new DummyCpuCoreFinder(1), // Fallback value ]); +// A type-safe alternative form: +$counter->getCountWithFallback(1); + +// Note that the result is memoized. $counter->getCount(); // e.g. 8 ``` @@ -68,30 +76,46 @@ $cores = (new CpuCoreCounter($finders))->getCount(); `FinderRegistry` provides two helpful entries: - `::getDefaultLogicalFinders()`: gives an ordered list of finders that will - look for the _logical_ CPU cores count + look for the _logical_ CPU cores count. - `::getDefaultPhysicalFinders()`: gives an ordered list of finders that will - look for the _physical_ CPU cores count + look for the _physical_ CPU cores count. -By default when using `CpuCoreCounter`, it will use the logical finders since +By default, when using `CpuCoreCounter`, it will use the logical finders since it is more likely what you are looking for and is what is used by PHP source to build the PHP binary. ### Checks what finders find what on your system -You have two commands available that provides insight about what the finders +You have three scrips available that provides insight about what the finders can find: -``` -$ make diagnose # From this repository -$ ./vendor/fidry/cpu-core-counter/bin/diagnose.php # From the library +```shell +# Checks what each given finder will find on your system with details about the +# information it had. +make diagnose # From this repository +./vendor/fidry/cpu-core-counter/bin/diagnose.php # From the library ``` And: +```shell +# Execute all finders and display the result they found. +make execute # From this repository +./vendor/fidry/cpu-core-counter/bin/execute.php # From the library ``` -$ make execute # From this repository -$ ./vendor/fidry/cpu-core-counter/bin/execute.php # From the library -``` + + +### Debug the results found + +You have 3 methods available to help you find out what happened: + +1. If you are using the default configuration of finder registries, you can check + the previous section which will provide plenty of information. +2. If what you are interested in is how many CPU cores were found, you can use + the `CpuCoreCounter::trace()` method. +3. If what you are interested in is how the calculation of CPU cores available + for parallelisation was done, you can inspect the values of `ParallelisationResult` + returned by `CpuCoreCounter::getAvailableForParallelisation()`. ## Backward Compatibility Promise (BCP) diff --git a/bin/trace.php b/bin/trace.php new file mode 100755 index 0000000..adb52e2 --- /dev/null +++ b/bin/trace.php @@ -0,0 +1,32 @@ +#!/usr/bin/env php + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +use Fidry\CpuCoreCounter\CpuCoreCounter; +use Fidry\CpuCoreCounter\Finder\FinderRegistry; + +require_once __DIR__.'/../vendor/autoload.php'; + +$separator = str_repeat('–', 80); + +echo 'With all finders...'.PHP_EOL.PHP_EOL; +echo (new CpuCoreCounter(FinderRegistry::getAllVariants()))->trace().PHP_EOL; +echo $separator.PHP_EOL.PHP_EOL; + +echo 'Logical CPU cores finders...'.PHP_EOL.PHP_EOL; +echo (new CpuCoreCounter(FinderRegistry::getDefaultLogicalFinders()))->trace().PHP_EOL; +echo $separator.PHP_EOL.PHP_EOL; + +echo 'Physical CPU cores finders...'.PHP_EOL.PHP_EOL; +echo (new CpuCoreCounter(FinderRegistry::getDefaultPhysicalFinders()))->trace().PHP_EOL; +echo $separator.PHP_EOL.PHP_EOL; diff --git a/infection.json5 b/infection.json5 index 36a0cf5..57864a2 100644 --- a/infection.json5 +++ b/infection.json5 @@ -23,23 +23,60 @@ "Fidry\\CpuCoreCounter\\CpuCoreCounter::getDefaultFinders" ] }, + "CastInt": { + "ignore": [ + // This is a bug or case handled by strict types. Not sure why + // infection can't detect it. + "Fidry\\CpuCoreCounter\\Finder\\EnvVariableFinder::isPositiveInteger" + ] + }, "CastString": { "ignore": [ - // I can't find a case in practice where this would happen + // I can't find a case in practice where this would happen. "Fidry\\CpuCoreCounter\\Executor\\ProcOpenExecutor::execute" ] }, + "Coalesce": { + "ignore": [ + // Cannot test this case. + "Fidry\\CpuCoreCounter\\CpuCoreCounter::getAvailableForParallelisation", + // Not interested in testing this case. + "Fidry\\CpuCoreCounter\\Finder\\ProcOpenBasedFinder::diagnose" + ] + }, "Continue_": false, + "DecrementInteger": { + "ignore": [ + "Fidry\\CpuCoreCounter\\CpuCoreCounter::getAvailableForParallelisation" + ] + }, "FunctionCallRemoval": { "ignore": [ - // I can't find a case in practice where this would happen + // I can't find a case in practice where this would happen. "Fidry\\CpuCoreCounter\\Executor\\ProcOpenExecutor::execute" ] }, + "GreaterThan": { + "ignore": [ + // This is an actual false positive. + "Fidry\\CpuCoreCounter\\CpuCoreCounter::getAvailableForParallelisation" + ] + }, + "IncrementInteger": { + "ignore": [ + "Fidry\\CpuCoreCounter\\CpuCoreCounter::getAvailableForParallelisation" + ] + }, + "OneZeroFloat": { + "ignore": [ + // Cannot test this case. + "Fidry\\CpuCoreCounter\\CpuCoreCounter::getAvailableForParallelisation" + ] + }, "PublicVisibility": false, "TrueValue": { "ignore": [ - // This is a case where the value does not matter + // This is a case where the value does not matter. "Fidry\\CpuCoreCounter\\Finder\\LscpuPhysicalFinder::countCpuCores" ] } diff --git a/phpstan.src.neon b/phpstan.src.neon index 83164c8..a35599e 100644 --- a/phpstan.src.neon +++ b/phpstan.src.neon @@ -1,5 +1,4 @@ parameters: - checkMissingIterableValueType: false inferPrivatePropertyTypeFromConstructor: true level: max @@ -8,3 +7,11 @@ parameters: - src tmpDir: .build/phpstan/src/ + + ignoreErrors: + + - path: src/Finder/EnvVariableFinder.php + message: '#find\(\) should return int\<1\, max\>\|null but returns int\|null\.#' + + - path: src/CpuCoreCounter.php + message: '#ParallelisationResult constructor expects int\<1\, max\>, int given\.#' diff --git a/phpstan.tests.neon b/phpstan.tests.neon index f145162..cdd6fde 100644 --- a/phpstan.tests.neon +++ b/phpstan.tests.neon @@ -1,5 +1,4 @@ parameters: - checkMissingIterableValueType: false inferPrivatePropertyTypeFromConstructor: true level: 6 @@ -10,6 +9,7 @@ parameters: tmpDir: .build/phpstan/tests/ ignoreErrors: + - identifier: missingType.iterableValue # This is a sanity check - path: tests/CpuCoreCounterTest.php diff --git a/src/CpuCoreCounter.php b/src/CpuCoreCounter.php index 098693b..8c8b55b 100644 --- a/src/CpuCoreCounter.php +++ b/src/CpuCoreCounter.php @@ -14,7 +14,14 @@ namespace Fidry\CpuCoreCounter; use Fidry\CpuCoreCounter\Finder\CpuCoreFinder; +use Fidry\CpuCoreCounter\Finder\EnvVariableFinder; use Fidry\CpuCoreCounter\Finder\FinderRegistry; +use InvalidArgumentException; +use function implode; +use function max; +use function sprintf; +use function sys_getloadavg; +use const PHP_EOL; final class CpuCoreCounter { @@ -36,6 +43,89 @@ public function __construct(?array $finders = null) $this->finders = $finders ?? FinderRegistry::getDefaultLogicalFinders(); } + /** + * @param positive-int|0 $reservedCpus Number of CPUs to reserve. This is useful when you want + * to reserve some CPUs for other processes. If the main + * process is going to be busy still, you may want to set + * this value to 1. + * @param non-zero-int|null $countLimit The maximum number of CPUs to return. If not provided, it + * may look for a limit in the environment variables, e.g. + * KUBERNETES_CPU_LIMIT. If negative, the limit will be + * the total number of cores found minus the absolute value. + * For instance if the system has 10 cores and countLimit=-2, + * then the effective limit considered will be 8. + * @param float|null $loadLimit Element of [0., 1.]. Percentage representing the + * amount of cores that should be used among the available + * resources. For instance, if set to 0.7, it will use 70% + * of the available cores, i.e. if 1 core is reserved, 11 + * cores are available and 5 are busy, it will use 70% + * of (11-1-5)=5 cores, so 3 cores. Set this parameter to null + * to skip this check. Beware that 1 does not mean "no limit", + * but 100% of the _available_ resources, i.e. with the + * previous example, it will return 5 cores. How busy is + * the system is determined by the system load average + * (see $systemLoadAverage). + * @param float|null $systemLoadAverage The system load average. If passed, it will use + * this information to limit the available cores based + * on the _available_ resources. For instance, if there + * is 10 cores but 3 are busy, then only 7 cores will + * be considered for further calculation. If set to + * `null`, it will use `sys_getloadavg()` to check the + * load of the system in the past minute. You can + * otherwise pass an arbitrary value. Should be a + * positive float. + * + * @see https://php.net/manual/en/function.sys-getloadavg.php + */ + public function getAvailableForParallelisation( + int $reservedCpus = 0, + ?int $countLimit = null, + ?float $loadLimit = null, + ?float $systemLoadAverage = 0. + ): ParallelisationResult { + self::checkCountLimit($countLimit); + self::checkLoadLimit($loadLimit); + self::checkSystemLoadAverage($systemLoadAverage); + + $totalCoreCount = $this->getCountWithFallback(1); + $availableCores = max(1, $totalCoreCount - $reservedCpus); + + // Adjust available CPUs based on current load + if (null !== $loadLimit) { + $correctedSystemLoadAverage = null === $systemLoadAverage + ? sys_getloadavg()[0] ?? 0. + : $systemLoadAverage; + + $availableCores = max( + 1, + $loadLimit * ($availableCores - $correctedSystemLoadAverage) + ); + } + + if (null === $countLimit) { + $correctedCountLimit = self::getKubernetesLimit(); + } else { + $correctedCountLimit = $countLimit > 0 + ? $countLimit + : max(1, $totalCoreCount + $countLimit); + } + + if (null !== $correctedCountLimit && $availableCores > $correctedCountLimit) { + $availableCores = $correctedCountLimit; + } + + return new ParallelisationResult( + $reservedCpus, + $countLimit, + $loadLimit, + $systemLoadAverage, + $correctedCountLimit, + $correctedSystemLoadAverage ?? $systemLoadAverage, + $totalCoreCount, + (int) $availableCores + ); + } + /** * @throws NumberOfCpuCoreNotFound * @@ -51,6 +141,48 @@ public function getCount(): int return $this->count; } + /** + * @param positive-int $fallback + * + * @return positive-int + */ + public function getCountWithFallback(int $fallback): int + { + try { + return $this->getCount(); + } catch (NumberOfCpuCoreNotFound $exception) { + return $fallback; + } + } + + /** + * This method is mostly for debugging purposes. + */ + public function trace(): string + { + $output = []; + + foreach ($this->finders as $finder) { + $output[] = sprintf( + 'Executing the finder "%s":', + $finder->toString() + ); + $output[] = $finder->diagnose(); + + $cores = $finder->find(); + + if (null !== $cores) { + $output[] = 'Result found: '.$cores; + + break; + } + + $output[] = '–––'; + } + + return implode(PHP_EOL, $output); + } + /** * @throws NumberOfCpuCoreNotFound * @@ -86,4 +218,51 @@ public function getFinderAndCores(): array throw NumberOfCpuCoreNotFound::create(); } + + /** + * @return positive-int|null + */ + public static function getKubernetesLimit(): ?int + { + $finder = new EnvVariableFinder('KUBERNETES_CPU_LIMIT'); + + return $finder->find(); + } + + private static function checkCountLimit(?int $countLimit): void + { + if (0 === $countLimit) { + throw new InvalidArgumentException( + 'The count limit must be a non zero integer. Got "0".' + ); + } + } + + private static function checkLoadLimit(?float $loadLimit): void + { + if (null === $loadLimit) { + return; + } + + if ($loadLimit < 0. || $loadLimit > 1.) { + throw new InvalidArgumentException( + sprintf( + 'The load limit must be in the range [0., 1.], got "%s".', + $loadLimit + ) + ); + } + } + + private static function checkSystemLoadAverage(?float $systemLoadAverage): void + { + if (null !== $systemLoadAverage && $systemLoadAverage < 0.) { + throw new InvalidArgumentException( + sprintf( + 'The system load average must be a positive float, got "%s".', + $systemLoadAverage + ) + ); + } + } } diff --git a/src/Finder/CpuInfoFinder.php b/src/Finder/CpuInfoFinder.php index dea4c41..8013877 100644 --- a/src/Finder/CpuInfoFinder.php +++ b/src/Finder/CpuInfoFinder.php @@ -49,10 +49,12 @@ public function diagnose(): string } return sprintf( - 'Found the file "%s" with the content:%s%s', + 'Found the file "%s" with the content:%s%s%sWill return "%s".', self::CPU_INFO_PATH, PHP_EOL, - $cpuInfo + $cpuInfo, + PHP_EOL, + self::countCpuCores($cpuInfo) ); } diff --git a/src/Finder/EnvVariableFinder.php b/src/Finder/EnvVariableFinder.php new file mode 100644 index 0000000..fa23278 --- /dev/null +++ b/src/Finder/EnvVariableFinder.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Fidry\CpuCoreCounter\Finder; + +use function getenv; +use function preg_match; +use function sprintf; +use function var_export; + +final class EnvVariableFinder implements CpuCoreFinder +{ + /** @var string */ + private $environmentVariableName; + + public function __construct(string $environmentVariableName) + { + $this->environmentVariableName = $environmentVariableName; + } + + public function diagnose(): string + { + $value = getenv($this->environmentVariableName); + + return sprintf( + 'parse(getenv(%s)=%s)=%s', + $this->environmentVariableName, + var_export($value, true), + self::isPositiveInteger($value) ? $value : 'null' + ); + } + + public function find(): ?int + { + $value = getenv($this->environmentVariableName); + + return self::isPositiveInteger($value) + ? (int) $value + : null; + } + + public function toString(): string + { + return sprintf( + 'getenv(%s)', + $this->environmentVariableName + ); + } + + /** + * @param string|false $value + */ + private static function isPositiveInteger($value): bool + { + return false !== $value + && 1 === preg_match('/^\d+$/', $value) + && (int) $value > 0; + } +} diff --git a/src/Finder/NProcFinder.php b/src/Finder/NProcFinder.php index 60a8ab7..c0f7a6f 100644 --- a/src/Finder/NProcFinder.php +++ b/src/Finder/NProcFinder.php @@ -30,10 +30,13 @@ final class NProcFinder extends ProcOpenBasedFinder private $all; /** - * @param bool $all If disabled will give the number of cores available for the current process only. + * @param bool $all If disabled will give the number of cores available for the current process + * only. This is disabled by default as it is known to be "buggy" on virtual + * environments as the virtualization tool, e.g. VMWare, might over-commit + * resources by default. */ public function __construct( - bool $all = true, + bool $all = false, ?ProcessExecutor $executor = null ) { parent::__construct($executor); diff --git a/src/Finder/ProcOpenBasedFinder.php b/src/Finder/ProcOpenBasedFinder.php index 793ec64..4d51f89 100644 --- a/src/Finder/ProcOpenBasedFinder.php +++ b/src/Finder/ProcOpenBasedFinder.php @@ -56,16 +56,19 @@ public function diagnose(): string return $failed ? sprintf( - 'Executed the command "%s" which wrote the following output to the STDERR:%s%s', + 'Executed the command "%s" which wrote the following output to the STDERR:%s%s%sWill return "null".', $command, PHP_EOL, - $stderr + $stderr, + PHP_EOL ) : sprintf( - 'Executed the command "%s" and got the following (STDOUT) output:%s%s', + 'Executed the command "%s" and got the following (STDOUT) output:%s%s%sWill return "%s".', $command, PHP_EOL, - $stdout + $stdout, + PHP_EOL, + $this->countCpuCores($stdout) ?? 'null' ); } diff --git a/src/ParallelisationResult.php b/src/ParallelisationResult.php new file mode 100644 index 0000000..1f29434 --- /dev/null +++ b/src/ParallelisationResult.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Fidry\CpuCoreCounter; + +/** + * @readonly + */ +final class ParallelisationResult +{ + /** + * @var positive-int|0 + */ + public $passedReservedCpus; + + /** + * @var non-zero-int|null + */ + public $passedCountLimit; + + /** + * @var float|null + */ + public $passedLoadLimit; + + /** + * @var float|null + */ + public $passedSystemLoadAverage; + + /** + * @var non-zero-int|null + */ + public $correctedCountLimit; + + /** + * @var float|null + */ + public $correctedSystemLoadAverage; + + /** + * @var positive-int + */ + public $totalCoresCount; + + /** + * @var positive-int + */ + public $availableCpus; + + /** + * @param positive-int|0 $passedReservedCpus + * @param non-zero-int|null $passedCountLimit + * @param non-zero-int|null $correctedCountLimit + * @param positive-int $totalCoresCount + * @param positive-int $availableCpus + */ + public function __construct( + int $passedReservedCpus, + ?int $passedCountLimit, + ?float $passedLoadLimit, + ?float $passedSystemLoadAverage, + ?int $correctedCountLimit, + ?float $correctedSystemLoadAverage, + int $totalCoresCount, + int $availableCpus + ) { + $this->passedReservedCpus = $passedReservedCpus; + $this->passedCountLimit = $passedCountLimit; + $this->passedLoadLimit = $passedLoadLimit; + $this->passedSystemLoadAverage = $passedSystemLoadAverage; + $this->correctedCountLimit = $correctedCountLimit; + $this->correctedSystemLoadAverage = $correctedSystemLoadAverage; + $this->totalCoresCount = $totalCoresCount; + $this->availableCpus = $availableCpus; + } +} diff --git a/tests/AvailableCpuCoresScenario.php b/tests/AvailableCpuCoresScenario.php new file mode 100644 index 0000000..0aa8fc9 --- /dev/null +++ b/tests/AvailableCpuCoresScenario.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Fidry\CpuCoreCounter\Test; + +use Fidry\CpuCoreCounter\Finder\CpuCoreFinder; +use Fidry\CpuCoreCounter\Finder\DummyCpuCoreFinder; + +/** + * @internal + * @readonly + */ +final class AvailableCpuCoresScenario +{ + /** @var list */ + public $finders; + /** @var array */ + public $environmentVariables; + /** @var positive-int|0 */ + public $reservedCpus; + /** @var non-zero-int|null */ + public $countLimit; + /** @var float|null */ + public $loadLimit; + /** @var float|null */ + public $systemLoadAverage; + /** @var positive-int */ + public $expected; + + /** + * @param list $finders + * @param array $environmentVariables + * @param positive-int|0 $reservedCpus + * @param non-zero-int|null $countLimit + * @param positive-int $expected + */ + public function __construct( + array $finders, + array $environmentVariables, + int $reservedCpus, + ?int $countLimit, + ?float $loadLimit, + ?float $systemLoadAverage, + int $expected + ) { + $this->finders = $finders; + $this->environmentVariables = $environmentVariables; + $this->reservedCpus = $reservedCpus; + $this->countLimit = $countLimit; + $this->loadLimit = $loadLimit; + $this->systemLoadAverage = $systemLoadAverage; + $this->expected = $expected; + } + + /** + * @param positive-int|null $coresCountFound + * @param array $environmentVariables + * @param positive-int|0|null $reservedCpus + * @param non-zero-int|null $countLimit + * @param positive-int $expected + * + * @return array{self} + */ + public static function create( + ?int $coresCountFound, + array $environmentVariables, + ?int $reservedCpus, + ?int $countLimit, + ?float $loadLimit, + ?float $systemLoadAverage, + int $expected + ): array { + $finders = null === $coresCountFound + ? [] + : [new DummyCpuCoreFinder($coresCountFound)]; + + return [ + new self( + $finders, + $environmentVariables, + $reservedCpus ?? 0, + $countLimit, + $loadLimit, + $systemLoadAverage ?? 0., + $expected + ), + ]; + } +} diff --git a/tests/CpuCoreCounterTest.php b/tests/CpuCoreCounterTest.php index cede7c1..f75d02f 100644 --- a/tests/CpuCoreCounterTest.php +++ b/tests/CpuCoreCounterTest.php @@ -13,6 +13,7 @@ namespace Fidry\CpuCoreCounter\Test; +use Closure; use Exception; use Fidry\CpuCoreCounter\CpuCoreCounter; use Fidry\CpuCoreCounter\Finder\CpuCoreFinder; @@ -22,6 +23,7 @@ use PHPUnit\Framework\TestCase; use function get_class; use function is_array; +use function sprintf; /** * @covers \Fidry\CpuCoreCounter\CpuCoreCounter @@ -30,6 +32,21 @@ */ final class CpuCoreCounterTest extends TestCase { + /** + * @var null|Closure(): void + */ + private $cleanupEnvironmentVariables; + + protected function tearDown(): void + { + $cleanupEnvironmentVariables = $this->cleanupEnvironmentVariables; + + if (null !== $cleanupEnvironmentVariables) { + ($cleanupEnvironmentVariables)(); + $this->cleanupEnvironmentVariables = null; + } + } + public function test_it_can_get_the_number_of_cpu_cores(): void { $counter = new CpuCoreCounter(); @@ -150,4 +167,420 @@ public static function cpuCoreFinderProvider(): iterable ]; })(); } + + /** + * @dataProvider availableCpuCoreProvider + */ + public function test_it_can_get_the_number_of_available_cpu_cores_for_parallelisation(AvailableCpuCoresScenario $scenario): void + { + $this->setUpEnvironmentVariables($scenario->environmentVariables); + + $counter = new CpuCoreCounter($scenario->finders); + + $actual = $counter->getAvailableForParallelisation( + $scenario->reservedCpus, + $scenario->countLimit, + $scenario->loadLimit, + $scenario->systemLoadAverage + ); + + self::assertSame($scenario->expected, $actual->availableCpus); + } + + public static function availableCpuCoreProvider(): iterable + { + yield 'no finder' => AvailableCpuCoresScenario::create( + null, + [], + 1, + null, + null, + null, + 1 + ); + + yield 'no finder, multiple CPUs reserved' => AvailableCpuCoresScenario::create( + null, + [], + 3, + null, + null, + null, + 1 + ); + + yield 'CPU count found: kubernetes limit set and lower than the count found' => AvailableCpuCoresScenario::create( + 5, + ['KUBERNETES_CPU_LIMIT' => 2], + 1, + null, + null, + null, + 2 + ); + + yield 'CPU count found: kubernetes limit set and higher than the count found' => AvailableCpuCoresScenario::create( + 5, + ['KUBERNETES_CPU_LIMIT' => 8], + 1, + null, + null, + null, + 4 + ); + + yield 'CPU count found: kubernetes limit set and equal to the count found' => AvailableCpuCoresScenario::create( + 5, + ['KUBERNETES_CPU_LIMIT' => 5], + 1, + null, + null, + null, + 4 + ); + + yield 'CPU count found: kubernetes limit set and equal to the count found after reserved CPUs' => AvailableCpuCoresScenario::create( + 5, + ['KUBERNETES_CPU_LIMIT' => 4], + 1, + null, + null, + null, + 4 + ); + + yield 'CPU count found: kubernetes limit set and limit set' => AvailableCpuCoresScenario::create( + 5, + ['KUBERNETES_CPU_LIMIT' => 2], + 1, + 3, + null, + null, + 3 + ); + + yield 'CPU count found: by default it reserves no CPU' => AvailableCpuCoresScenario::create( + 5, + [], + null, + null, + null, + null, + 5 + ); + + yield 'CPU count found higher than the count limit passed' => AvailableCpuCoresScenario::create( + 5, + [], + 1, + 3, + null, + null, + 3 + ); + + yield 'CPU count found, negative limit passed' => AvailableCpuCoresScenario::create( + 5, + [], + 0, + -2, + null, + null, + 3 + ); + + yield 'CPU count found, negative limit beyond available resources' => AvailableCpuCoresScenario::create( + 5, + [], + 0, + -10, + null, + null, + 1 + ); + + yield 'CPU count found, with reserved CPU, negative limit passed' => AvailableCpuCoresScenario::create( + 5, + [], + 1, + -2, + null, + null, + 3 + ); + + yield 'CPU count found, multiple CPUs reserved' => AvailableCpuCoresScenario::create( + 5, + [], + 2, + null, + null, + null, + 3 + ); + + yield 'CPU count found, all CPUs reserved' => AvailableCpuCoresScenario::create( + 5, + [], + 5, + null, + null, + null, + 1 + ); + + yield 'CPU count found, over half the cores are used and no limit is set' => AvailableCpuCoresScenario::create( + 11, + [], + 1, + null, + null, + 6., + 10 + ); + + yield 'CPU count found, over half the cores are used and a limit is set' => AvailableCpuCoresScenario::create( + 11, + [], + 1, + null, + 1., + 6., + 4 + ); + + yield 'CPU count found, the CPUs are overloaded' => AvailableCpuCoresScenario::create( + 11, + [], + 1, + null, + .9, + 9.5, + 1 + ); + + yield 'CPU count found, the load limit is set, but there is several CPUs available still' => AvailableCpuCoresScenario::create( + 11, + [], + 1, + null, + .5, + 6., + 2 + ); + + yield 'CPU count found, the CPUs are at completely overloaded' => AvailableCpuCoresScenario::create( + 11, + [], + 1, + null, + .5, + 11., + 1 + ); + + yield 'CPU count found, the CPUs are overloaded but no load limit per CPU' => AvailableCpuCoresScenario::create( + 11, + [], + 1, + null, + null, + 9.5, + 10 + ); + + yield 'it rounds the available cores to the lower int (less than half)' => AvailableCpuCoresScenario::create( + 32, + [], + 0, + null, + .1, + 0., + 3 + ); + + yield 'it rounds the available cores to the lower int (perfect half)' => AvailableCpuCoresScenario::create( + 7, + [], + 0, + null, + .5, + 0., + 3 + ); + + yield 'it rounds the available cores to the lower int (more than half)' => AvailableCpuCoresScenario::create( + 36, + [], + 0, + null, + .1, + 0., + 3 + ); + } + + /** + * @dataProvider countLimitProvider + */ + public function test_it_does_not_accept_invalid_count_limit( + int $countLimit, + ?string $expectedExceptionMessage + ): void { + $cpuCoreCounter = new CpuCoreCounter(); + + if (null !== $expectedExceptionMessage) { + $this->expectExceptionMessage($expectedExceptionMessage); + } + + $cpuCoreCounter->getAvailableForParallelisation( + 1, + $countLimit + ); + + if (null === $expectedExceptionMessage) { + $this->addToAssertionCount(1); + } + } + + public static function countLimitProvider(): iterable + { + yield 'below limit' => [ + -2, + null, + ]; + + yield 'within the limit (lower)' => [ + -1, + null, + ]; + + yield 'invalid limit' => [ + 0, + 'The count limit must be a non zero integer. Got "0".', + ]; + + yield 'within the limit (upper)' => [ + 1, + null, + ]; + + yield 'above limit' => [ + 2, + null, + ]; + } + + /** + * @dataProvider loadLimitProvider + */ + public function test_it_does_not_accept_invalid_load_limit( + float $loadLimit, + ?string $expectedExceptionMessage + ): void { + $cpuCoreCounter = new CpuCoreCounter(); + + if (null !== $expectedExceptionMessage) { + $this->expectExceptionMessage($expectedExceptionMessage); + } + + $cpuCoreCounter->getAvailableForParallelisation( + 1, + null, + $loadLimit + ); + + if (null === $expectedExceptionMessage) { + $this->addToAssertionCount(1); + } + } + + public static function loadLimitProvider(): iterable + { + yield 'below limit' => [ + -0.001, + 'The load limit must be in the range [0., 1.], got "-0.001".', + ]; + + yield 'within the limit (min)' => [ + 0., + null, + ]; + + yield 'within the limit (max)' => [ + 1., + null, + ]; + + yield 'above limit' => [ + 1.001, + 'The load limit must be in the range [0., 1.], got "1.001".', + ]; + } + + /** + * @dataProvider systemLoadAverageProvider + */ + public function test_it_does_not_accept_invalid_system_load_average( + float $systemLoadAverage, + ?string $expectedExceptionMessage + ): void { + $cpuCoreCounter = new CpuCoreCounter(); + + if (null !== $expectedExceptionMessage) { + $this->expectExceptionMessage($expectedExceptionMessage); + } + + $cpuCoreCounter->getAvailableForParallelisation( + 1, + null, + null, + $systemLoadAverage + ); + + if (null === $expectedExceptionMessage) { + $this->addToAssertionCount(1); + } + } + + public static function systemLoadAverageProvider(): iterable + { + yield 'below limit' => [ + -0.001, + 'The system load average must be a positive float, got "-0.001".', + ]; + + yield 'within the limit' => [ + 0., + null, + ]; + } + + /** + * @param array $environmentVariables + */ + private function setUpEnvironmentVariables(array $environmentVariables): void + { + $cleanupCalls = []; + + foreach ($environmentVariables as $environmentName => $environmentValue) { + putenv( + sprintf( + '%s=%s', + $environmentName, + $environmentValue + ) + ); + + $cleanupCalls[] = static function () use ($environmentName): void { + putenv($environmentName); + }; + } + + $this->cleanupEnvironmentVariables = static function () use ($cleanupCalls): void { + foreach ($cleanupCalls as $cleanupCall) { + $cleanupCall(); + } + }; + } } diff --git a/tests/Finder/EnvVariableFinderTest.php b/tests/Finder/EnvVariableFinderTest.php new file mode 100644 index 0000000..5638cde --- /dev/null +++ b/tests/Finder/EnvVariableFinderTest.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Fidry\CpuCoreCounter\Test\Finder; + +use Fidry\CpuCoreCounter\Finder\EnvVariableFinder; +use PHPUnit\Framework\TestCase; +use function sprintf; + +/** + * @covers \Fidry\CpuCoreCounter\Finder\EnvVariableFinder + * + * @internal + */ +final class EnvVariableFinderTest extends TestCase +{ + protected function tearDown(): void + { + putenv('CI_CPU_LIMIT'); + } + + public function test_it_can_describe_itself(): void + { + $finder = new EnvVariableFinder('CI_CPU_LIMIT'); + + self::assertSame( + 'getenv(CI_CPU_LIMIT)', + $finder->toString() + ); + } + + /** + * @dataProvider envProvider + */ + public function test_it_tries_to_get_the_number_of_cores( + ?string $envValue, + ?int $expected + ): void { + $finder = new EnvVariableFinder('CI_CPU_LIMIT'); + + if (null !== $envValue) { + putenv(sprintf('CI_CPU_LIMIT=%s', $envValue)); + } + + self::assertSame($expected, $finder->find()); + } + + public static function envProvider(): iterable + { + yield 'int value' => [ + '18', + 18, + ]; + + yield 'zero' => [ + '0', + null, + ]; + + yield 'negative int value' => [ + '-3', + null, + ]; + + yield 'no value' => [ + '', + null, + ]; + + yield 'no environment variable' => [ + null, + null, + ]; + + yield 'string value' => [ + 'something', + null, + ]; + + yield 'numeric value' => [ + '18.3', + null, + ]; + + yield 'int value in string' => [ + '"something 18"', + null, + ]; + } +} diff --git a/tests/Finder/NProcFinderTest.php b/tests/Finder/NProcFinderTest.php index 61086ce..2b84e04 100644 --- a/tests/Finder/NProcFinderTest.php +++ b/tests/Finder/NProcFinderTest.php @@ -53,26 +53,26 @@ public function test_it_can_describe_itself(CpuCoreFinder $finder, string $expec public static function finderProvider(): iterable { - yield [ - new NProcFinder(true), + yield 'default constructor parameters' => [ + new NProcFinder(), sprintf( - '%s(all=true)', + '%s(all=false)', FinderShortClassName::get(new NProcFinder()) ) ]; - yield [ - new NProcFinder(), + yield 'without all' => [ + new NProcFinder(false), sprintf( - '%s(all=true)', + '%s(all=false)', FinderShortClassName::get(new NProcFinder()) ) ]; - yield [ - new NProcFinder(false), + yield 'with all' => [ + new NProcFinder(true), sprintf( - '%s(all=false)', + '%s(all=true)', FinderShortClassName::get(new NProcFinder()) ) ]; diff --git a/tests/Finder/ProcOpenBasedFinderTestCase.php b/tests/Finder/ProcOpenBasedFinderTestCase.php index a0a79ba..0801708 100644 --- a/tests/Finder/ProcOpenBasedFinderTestCase.php +++ b/tests/Finder/ProcOpenBasedFinderTestCase.php @@ -63,8 +63,8 @@ public function test_it_can_do_a_diagnosis( public static function diagnosisProvider(): iterable { - $stdoutResultRegex = '/^Executed the command ".*" and got the following \(STDOUT\) output:\nsmth in stdout$/'; - $stderrResultRegex = '/^Executed the command ".*" which wrote the following output to the STDERR:\nsmth in stderr$/'; + $stdoutResultRegex = '/^Executed the command ".*" and got the following \(STDOUT\) output:\nsmth in stdout\nWill return "(null|\d)"\.$/'; + $stderrResultRegex = '/^Executed the command ".*" which wrote the following output to the STDERR:\nsmth in stderr\nWill return "(null|\d)"\.$/'; yield 'could not execute command' => [ null,