diff --git a/src/Configuration.php b/src/Configuration.php new file mode 100644 index 0000000..35d7199 --- /dev/null +++ b/src/Configuration.php @@ -0,0 +1,107 @@ +<?php + +/* + * This file is part of the Webmozarts Console Parallelization package. + * + * (c) Webmozarts GmbH <office@webmozarts.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Webmozarts\Console\Parallelization; + +use Symfony\Component\Console\Command\Command; +use Webmozart\Assert\Assert; +use function ceil; +use function sprintf; + +final class Configuration +{ + private $segmentSize; + private $rounds; + private $batchSize; + private $batches; + + public function __construct( + bool $numberOfProcessesDefined, + int $numberOfProcesses, + int $numberOfItems, + int $segmentSize, + int $batchSize + ) { + Assert::greaterThan( + $numberOfProcesses, + 0, + sprintf( + 'Expected the number of processes to be 1 or greater. Got "%s"', + $numberOfProcesses + ) + ); + Assert::natural( + $numberOfItems, + sprintf( + 'Expected the number of items to be 0 or greater. Got "%s"', + $numberOfItems + ) + ); + Assert::greaterThan( + $segmentSize, + 0, + sprintf( + 'Expected the segment size to be 1 or greater. Got "%s"', + $segmentSize + ) + ); + Assert::greaterThan( + $batchSize, + 0, + sprintf( + 'Expected the batch size to be 1 or greater. Got "%s"', + $batchSize + ) + ); + + // We always check those (and not the calculated ones) since they come from the command + // configuration so an issue there hints on a misconfiguration which should be fixed. + Assert::greaterThanEq( + $segmentSize, + $batchSize, + sprintf( + 'Expected the segment size ("%s") to be greater or equal to the batch size ("%s")', + $segmentSize, + $batchSize + ) + ); + + $this->segmentSize = 1 === $numberOfProcesses && !$numberOfProcessesDefined + ? $numberOfItems + : $segmentSize + ; + $this->rounds = (int) (1 === $numberOfProcesses ? 1 : ceil($numberOfItems / $segmentSize)); + $this->batchSize = $batchSize; + $this->batches = (int) (ceil($segmentSize / $batchSize) * $this->rounds); + } + + public function getSegmentSize(): int + { + return $this->segmentSize; + } + + public function getRounds(): int + { + return $this->rounds; + } + + public function getBatchSize(): int + { + return $this->batchSize; + } + + public function getBatches(): int + { + return $this->batches; + } +} diff --git a/src/Parallelization.php b/src/Parallelization.php index a24cf9c..d197225 100644 --- a/src/Parallelization.php +++ b/src/Parallelization.php @@ -19,7 +19,6 @@ use function array_filter; use function array_merge; use function array_slice; -use function ceil; use function count; use function getcwd; use function implode; @@ -294,40 +293,25 @@ protected function executeMasterProcess( $numberOfProcesses = $parallelizationInput->getNumberOfProcesses(); $hasItem = null !== $parallelizationInput->getItem(); $items = $hasItem ? [$parallelizationInput->getItem()] : $this->fetchItems($input); - $count = count($items); - $segmentSize = 1 === $numberOfProcesses && !$isNumberOfProcessesDefined ? $count : $this->getSegmentSize(); - $batchSize = $this->getBatchSize(); - $rounds = 1 === $numberOfProcesses ? 1 : ceil($count * 1.0 / $segmentSize); - $batches = ceil($segmentSize * 1.0 / $batchSize) * $rounds; + $numberOfItems = count($items); - Assert::greaterThan( + $config = new Configuration( + $isNumberOfProcessesDefined, $numberOfProcesses, - 0, - sprintf( - 'Requires at least one process. Got "%s"', - $input->getOption('processes') - ) + $numberOfItems, + $this->getSegmentSize(), + $this->getBatchSize() ); - if (!$hasItem && 1 !== $numberOfProcesses) { - // Shouldn't check this when only one item has been specified or - // when no child processes is used - Assert::greaterThanEq( - $segmentSize, - $batchSize, - sprintf( - 'The segment size should always be greater or equal to ' - .'the batch size. Got respectively "%d" and "%d"', - $segmentSize, - $batchSize - ) - ); - } + $segmentSize = $config->getSegmentSize(); + $rounds = $config->getRounds(); + $batches = $config->getBatches(); + $batchSize = $config->getBatchSize(); $output->writeln(sprintf( 'Processing %d %s in segments of %d, batches of %d, %d %s, %d %s in %d %s', - $count, - $this->getItemName($count), + $numberOfItems, + $this->getItemName($numberOfItems), $segmentSize, $batchSize, $rounds, @@ -339,16 +323,18 @@ protected function executeMasterProcess( )); $output->writeln(''); - $progressBar = new ProgressBar($output, $count); + $progressBar = new ProgressBar($output, $numberOfItems); $progressBar->setFormat('debug'); $progressBar->start(); - if ($count <= $segmentSize || (1 === $numberOfProcesses && !$isNumberOfProcessesDefined)) { + if ($numberOfItems <= $segmentSize + || (1 === $numberOfProcesses && !$parallelizationInput->isNumberOfProcessesDefined()) + ) { // Run in the master process $itemsChunks = array_chunk( $items, - $this->getBatchSize(), + $batchSize, false ); @@ -407,8 +393,8 @@ function (string $type, string $buffer) use ($progressBar, $output, $terminalWid $output->writeln(''); $output->writeln(sprintf( 'Processed %d %s.', - $count, - $this->getItemName($count) + $numberOfItems, + $this->getItemName($numberOfItems) )); $this->runAfterLastCommand($input, $output); diff --git a/tests/ConfigurationTest.php b/tests/ConfigurationTest.php new file mode 100644 index 0000000..d9511a3 --- /dev/null +++ b/tests/ConfigurationTest.php @@ -0,0 +1,347 @@ +<?php + +/* + * This file is part of the Webmozarts Console Parallelization package. + * + * (c) Webmozarts GmbH <office@webmozarts.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Webmozarts\Console\Parallelization; + +use InvalidArgumentException; +use PHPUnit\Framework\TestCase; +use function func_get_args; + +/** + * @covers \Webmozarts\Console\Parallelization\Configuration + */ +final class ConfigurationTest extends TestCase +{ + /** + * @dataProvider valuesProvider + */ + public function test_it_can_be_instantiated( + bool $numberOfProcessesDefined, + int $numberOfProcesses, + int $numberOfItems, + int $segmentSize, + int $batchSize, + int $expectedSegmentSize, + int $expectedBatchSize, + int $expectedRounds, + int $expectedBatches + ): void { + $config = new Configuration( + $numberOfProcessesDefined, + $numberOfProcesses, + $numberOfItems, + $segmentSize, + $batchSize + ); + + $this->assertSame($expectedSegmentSize, $config->getSegmentSize()); + $this->assertSame($expectedBatchSize, $config->getBatchSize()); + $this->assertSame($expectedRounds, $config->getRounds()); + $this->assertSame($expectedBatches, $config->getBatches()); + } + + /** + * @dataProvider invalidValuesProvider + */ + public function test_it_cannot_be_instantiated_with_invalid_values( + int $numberOfProcesses, + int $numberOfItems, + int $segmentSize, + int $batchSize, + string $expectedErrorMessage + ): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedErrorMessage); + + new Configuration( + true, + $numberOfProcesses, + $numberOfItems, + $segmentSize, + $batchSize + ); + } + + public static function valuesProvider(): iterable + { + yield 'empty' => self::createInputArgs( + false, + 1, + 0, + 1, + 1, + 0, + 1, + 1, + 1 + ); + + yield 'only one default process: the segment size is the number of items' => self::createInputArgs( + false, + 1, + 50, + 1, + 1, + 50, + 1, + 1, + 1 + ); + + yield 'an arbitrary number of processes given: the segment size is the segment size given' => self::createInputArgs( + true, + 7, + 50, + 3, + 1, + 3, + 1, + 17, + 51 + ); + + yield 'one process given: the segment size is the segment size given' => self::createInputArgs( + true, + 1, + 50, + 3, + 1, + 3, + 1, + 1, + 3 + ); + + // Invalid domain case but we add this test to capture this behaviour nonetheless + yield 'multiple default processes: the segment size is the segment size given' => self::createInputArgs( + true, + 7, + 50, + 3, + 1, + 3, + 1, + 17, + 51 + ); + + yield 'there is no rounds if there is no items' => self::createInputArgs( + false, + 1, + 0, + 1, + 1, + 0, + 1, + 1, + 1 + ); + + yield 'there is only one round if only one process (default)' => self::createInputArgs( + false, + 1, + 50, + 1, + 1, + 50, + 1, + 1, + 1 + ); + + yield 'there is only one round if only one process (arbitrary)' => self::createInputArgs( + true, + 1, + 50, + 1, + 1, + 1, + 1, + 1, + 1 + ); + + yield 'there is enough rounds to reach the number of items with the given segment size (half)' => self::createInputArgs( + true, + 2, + 50, + 25, + 1, + 25, + 1, + 2, + 50 + ); + + yield 'there is enough rounds to reach the number of items with the given segment size (upper)' => self::createInputArgs( + true, + 2, + 50, + 15, + 1, + 15, + 1, + 4, + 60 + ); + + yield 'there is enough rounds to reach the number of items with the given segment size (lower)' => self::createInputArgs( + true, + 2, + 50, + 40, + 1, + 40, + 1, + 2, + 80 + ); + + yield 'the batch size used is the batch size given' => self::createInputArgs( + false, + 1, + 0, + 10, + 7, + 0, + 7, + 1, + 2 + ); + + yield 'there is enough batches to process all the items of a given segment (half)' => self::createInputArgs( + true, + 2, + 50, + 30, + 15, + 30, + 15, + 2, + 4 + ); + + yield 'there is enough batches to process all the items of a given segment (upper)' => self::createInputArgs( + true, + 2, + 50, + 30, + 10, + 30, + 10, + 2, + 6 + ); + + yield 'there is enough batches to process all the items of a given segment (lower)' => self::createInputArgs( + true, + 2, + 50, + 30, + 25, + 30, + 25, + 2, + 4 + ); + } + + public static function invalidValuesProvider(): iterable + { + yield 'invalid number of processes (limit)' => [ + 0, + 0, + 1, + 1, + 'Expected the number of processes to be 1 or greater. Got "0"', + ]; + + yield 'invalid number of processes' => [ + -1, + 0, + 1, + 1, + 'Expected the number of processes to be 1 or greater. Got "-1"', + ]; + + yield 'invalid number of items (limit)' => [ + 1, + -1, + 1, + 1, + 'Expected the number of items to be 0 or greater. Got "-1"', + ]; + + yield 'invalid number of items' => [ + 1, + -10, + 1, + 1, + 'Expected the number of items to be 0 or greater. Got "-10"', + ]; + + yield 'invalid segment size (limit)' => [ + 1, + 0, + 0, + 1, + 'Expected the segment size to be 1 or greater. Got "0"', + ]; + + yield 'invalid segment size' => [ + 1, + 0, + -1, + 1, + 'Expected the segment size to be 1 or greater. Got "-1"', + ]; + + yield 'invalid batch size (limit)' => [ + 1, + 0, + 1, + 0, + 'Expected the batch size to be 1 or greater. Got "0"', + ]; + + yield 'invalid batch size' => [ + 1, + 0, + 1, + -1, + 'Expected the batch size to be 1 or greater. Got "-1"', + ]; + + yield 'segment size lower than batch size' => [ + 1, + 0, + 1, + 10, + 'Expected the segment size ("1") to be greater or equal to the batch size ("10")', + ]; + } + + private static function createInputArgs( + bool $numberOfProcessesDefined, + int $numberOfProcesses, + int $numberOfItems, + int $segmentSize, + int $batchSize, + int $expectedSegmentSize, + int $expectedBatchSize, + int $expectedRounds, + int $expectedBatches + ): array { + return func_get_args(); + } +} diff --git a/tests/ParallelizationIntegrationTest.php b/tests/ParallelizationIntegrationTest.php index 62bea7f..6f72e33 100644 --- a/tests/ParallelizationIntegrationTest.php +++ b/tests/ParallelizationIntegrationTest.php @@ -78,7 +78,7 @@ public function test_it_can_run_the_command_without_sub_processes(): void if ($this->isSymfony3()) { $this->assertSame( <<<'EOF' -Processing 2 movies in segments of 2, batches of 50, 1 round, 1 batches in 1 process +Processing 2 movies in segments of 2, batches of 50, 1 round, 1 batch in 1 process 0/2 [>---------------------------] 0% < 1 sec/< 1 sec 10.0 MiB 1/2 [==============>-------------] 50% < 1 sec/< 1 sec 10.0 MiB @@ -94,7 +94,7 @@ public function test_it_can_run_the_command_without_sub_processes(): void } else { $this->assertSame( <<<'EOF' -Processing 2 movies in segments of 2, batches of 50, 1 round, 1 batches in 1 process +Processing 2 movies in segments of 2, batches of 50, 1 round, 1 batch in 1 process 0/2 [>---------------------------] 0% < 1 sec/< 1 sec 10.0 MiB 2/2 [============================] 100% < 1 sec/< 1 sec 10.0 MiB @@ -124,7 +124,7 @@ public function test_it_can_run_the_command_with_a_single_sub_processes(): void if ($this->isSymfony3()) { $this->assertSame( <<<'EOF' -Processing 2 movies in segments of 50, batches of 50, 1 round, 1 batches in 1 process +Processing 2 movies in segments of 50, batches of 50, 1 round, 1 batch in 1 process 0/2 [>---------------------------] 0% < 1 sec/< 1 sec 10.0 MiB 1/2 [==============>-------------] 50% < 1 sec/< 1 sec 10.0 MiB @@ -140,7 +140,7 @@ public function test_it_can_run_the_command_with_a_single_sub_processes(): void } else { $this->assertSame( <<<'EOF' -Processing 2 movies in segments of 50, batches of 50, 1 round, 1 batches in 1 process +Processing 2 movies in segments of 50, batches of 50, 1 round, 1 batch in 1 process 0/2 [>---------------------------] 0% < 1 sec/< 1 sec 10.0 MiB 2/2 [============================] 100% < 1 sec/< 1 sec 10.0 MiB @@ -170,7 +170,7 @@ public function test_it_can_run_the_command_with_multiple_processes(): void if ($this->isSymfony3()) { $this->assertSame( <<<'EOF' -Processing 2 movies in segments of 50, batches of 50, 1 rounds, 1 batches in 2 processes +Processing 2 movies in segments of 50, batches of 50, 1 round, 1 batch in 2 processes 0/2 [>---------------------------] 0% < 1 sec/< 1 sec 10.0 MiB 1/2 [==============>-------------] 50% < 1 sec/< 1 sec 10.0 MiB @@ -186,7 +186,7 @@ public function test_it_can_run_the_command_with_multiple_processes(): void } else { $this->assertSame( <<<'EOF' -Processing 2 movies in segments of 50, batches of 50, 1 rounds, 1 batches in 2 processes +Processing 2 movies in segments of 50, batches of 50, 1 round, 1 batch in 2 processes 0/2 [>---------------------------] 0% < 1 sec/< 1 sec 10.0 MiB 2/2 [============================] 100% < 1 sec/< 1 sec 10.0 MiB @@ -216,7 +216,7 @@ public function test_it_can_run_the_command_with_one_process_as_child_process(): if ($this->isSymfony3()) { $this->assertSame( <<<'EOF' -Processing 2 movies in segments of 50, batches of 50, 1 round, 1 batches in 1 process +Processing 2 movies in segments of 50, batches of 50, 1 round, 1 batch in 1 process 0/2 [>---------------------------] 0% < 1 sec/< 1 sec 10.0 MiB 1/2 [==============>-------------] 50% < 1 sec/< 1 sec 10.0 MiB @@ -232,7 +232,7 @@ public function test_it_can_run_the_command_with_one_process_as_child_process(): } else { $this->assertSame( <<<'EOF' -Processing 2 movies in segments of 50, batches of 50, 1 round, 1 batches in 1 process +Processing 2 movies in segments of 50, batches of 50, 1 round, 1 batch in 1 process 0/2 [>---------------------------] 0% < 1 sec/< 1 sec 10.0 MiB 2/2 [============================] 100% < 1 sec/< 1 sec 10.0 MiB