diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11d7425..1a28a5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,10 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: ['8.2', '8.3'] + php: ['8.2', '8.3', '8.4'] name: Composer PHP ${{ matrix.php }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} @@ -25,10 +25,10 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: ['8.2', '8.3'] + php: ['8.2', '8.3', '8.4'] name: PHPUnit PHP ${{ matrix.php }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} @@ -45,36 +45,36 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: ['8.2', '8.3'] + php: ['8.2', '8.3', '8.4'] name: PHPStan PHP ${{ matrix.php }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} coverage: none - run: composer update --no-progress --no-interaction - - run: vendor/bin/phpstan analyze src --level=5 --no-progress + - run: vendor/bin/phpstan analyze src --level=9 --no-progress phpstan_lowest: runs-on: ubuntu-latest strategy: matrix: - php: ['8.2', '8.3'] + php: ['8.2', '8.3', '8.4'] name: PHPStan Lowest PHP ${{ matrix.php }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} coverage: none - run: composer update --prefer-lowest --no-progress --no-interaction - - run: vendor/bin/phpstan analyze src --level=5 --no-progress + - run: vendor/bin/phpstan analyze src --level=9 --no-progress codesniffer: runs-on: ubuntu-latest name: PHPCS steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - run: composer update --no-progress --no-interaction - run: vendor/bin/phpcs src --standard=PSR2 -n diff --git a/.gitignore b/.gitignore index 9e5eb88..c1a1503 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ composer.lock coverage.xml coverage* .phpunit.cache +.DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fe731e1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,117 @@ +# Contributing to Multitron + +Thank you for your interest in contributing to Multitron! This document provides guidelines and instructions for contributing. + +## Code of Conduct + +Be respectful, professional, and constructive in all interactions. + +## How to Contribute + +### Reporting Bugs + +1. Check if the bug has already been reported in [Issues](https://github.com/riki137/multitron/issues) +2. If not, create a new issue with: + - Clear, descriptive title + - Detailed description of the bug + - Steps to reproduce + - Expected vs actual behavior + - PHP version and environment details + - Minimal code example if possible + +### Suggesting Features + +1. Check existing [Issues](https://github.com/riki137/multitron/issues) and [Pull Requests](https://github.com/riki137/multitron/pulls) +2. Create an issue describing: + - The problem you're trying to solve + - Your proposed solution + - Any alternatives you've considered + - Examples of how it would be used + +### Submitting Pull Requests + +1. **Fork and Clone** + ```bash + git clone https://github.com/YOUR-USERNAME/multitron.git + cd multitron + ``` + +2. **Install Dependencies** + ```bash + composer install + ``` + +3. **Create a Branch** + ```bash + git checkout -b feature/your-feature-name + # or + git checkout -b fix/your-bug-fix + ``` + +4. **Make Your Changes** + - Follow existing code style (PSR-2) + - Add tests for new features + - Update documentation as needed + - Keep commits focused and atomic + +5. **Run Tests** + ```bash + vendor/bin/phpunit + vendor/bin/phpstan analyze src --level=9 + vendor/bin/phpcs src --standard=PSR2 -n + ``` + +6. **Commit Your Changes** + ```bash + git commit -m "Brief description of changes" + ``` + + Good commit message examples: + - `Add support for custom IPC adapters` + - `Fix memory leak in ProgressClient` + - `Update Laravel integration documentation` + +7. **Push and Create PR** + ```bash + git push origin feature/your-feature-name + ``` + Then open a Pull Request on GitHub. + +## Development Guidelines + +### Code Style + +- Follow PSR-2 coding standard +- Use strict types: `declare(strict_types=1);` +- Use typed properties where possible +- Prefer `readonly` for immutable properties +- Prefer `final` classes +- Document complex types with PHPDoc + +### Testing + +- Write tests for new features +- Ensure existing tests pass +- Aim for good code coverage +- Test both success and failure cases + +### Documentation + +- Update README.md if adding user-facing features +- Update relevant integration docs (Symfony, Laravel, Nette, Native) +- Add PHPDoc comments for public APIs +- Include code examples for complex features + +### Architecture + +- Maintain separation of concerns +- Use dependency injection +- Follow existing patterns in the codebase +- Keep backwards compatibility in mind + +## Questions? + +Feel free to open an issue for any questions about contributing. + +Thank you for making Multitron better! 🚀 + diff --git a/README.md b/README.md index d4b10fc..f287859 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ [![License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) [![Build Status](https://img.shields.io/github/actions/workflow/status/riki137/multitron/ci.yml?branch=master&style=flat-square)](https://github.com/riki137/multitron/actions?query=workflow%3Aci+branch%3Amaster) [![Code Coverage](https://codecov.io/gh/riki137/multitron/branch/master/graph/badge.svg?style=flat-square)](https://codecov.io/gh/riki137/multitron) -[![PHPStan Level 9](https://img.shields.io/badge/PHPStan-level%209-brightgreen.svg?style=flat-square)](https://github.com/phpstan/phpstan) +![PHP Version](https://img.shields.io/packagist/php-v/riki137/multitron.svg) +![PHPStan Level 9](https://img.shields.io/badge/PHPStan-level%209-brightgreen.svg?style=flat-square) **Multitron** is a powerful, high-performance **PHP task orchestrator** designed to simplify parallel processing, concurrency, and automation in PHP applications. Quickly execute complex tasks asynchronously, maximizing the efficiency and scalability of your workflows. @@ -17,6 +18,24 @@ --- +## Table of Contents + +- [Why Choose Multitron?](#why-choose-multitron) +- [Requirements](#requirements) +- [Installation](#installation) +- [Usage](#usage) + - [Basic Example](#usage) + - [Running Tasks](#usage) + - [Central Cache](#central-cache) + - [Reporting Progress](#reporting-progress) + - [Partitioned Tasks](#partitioned-tasks) + - [Accessing CLI Options](#accessing-cli-options) + - [Custom Progress Output](#custom-progress-output) +- [Contributing](#contribute-to-multitron) +- [License](#license) + +--- + ## Why Choose Multitron? * 🔄 **Efficient Task Dependency Management**: Clearly define task dependencies with intuitive declarative syntax. @@ -224,6 +243,69 @@ Implement the factory to send progress anywhere you like. --- +## Troubleshooting + +### "ext-pcntl is required" +Multitron requires the `pcntl` extension for process management. Install it: + +```bash +# Ubuntu/Debian +sudo apt-get install php-pcntl + +# macOS (usually included) +# If missing, use Homebrew PHP +brew install php +``` + +### Worker processes hang or timeout +- Check the worker timeout setting (default 60s): + ```php + $factory->setWorkerTimeout(300.0); // 5 minutes + ``` +- Ensure your tasks don't have infinite loops +- Check for deadlocks in IPC communication + +### "WorkerCommand not found" error +Make sure you've registered the worker command: + +```php +// Symfony - register extension in Kernel +$container->registerExtension(new MultitronExtension()); + +// Laravel - add to bootstrap/providers.php +MultitronServiceProvider::class, + +// Native - add to application +$app->add($factory->getWorkerCommand()); +``` + +### Memory limit errors +Adjust the memory limit: +```bash +php bin/console app:tasks -m 1G +``` + +Or set it in your task command initialization. + +### Tasks not running in parallel +- Check concurrency setting: `php bin/console app:tasks -c 8` +- Verify task dependencies aren't creating a serial execution path +- Ensure you have enough CPU cores available + +### Progress not updating +- Check the update interval: `php bin/console app:tasks -u 0.5` +- In CI environments, use `--interactive=no` for plain output +- Verify you're calling `$comm->progress->addDone()` in your tasks + +### Cache data not persisting between tasks +- Ensure you're using `$comm->cache->write()` before `read()` +- Remember to call `->await()` on read promises +- Cache is process-scoped; it doesn't persist after the command finishes + +For more help, check existing [GitHub Issues](https://github.com/riki137/multitron/issues) or open a new one. + +--- + ## Contribute to Multitron! Your feedback, issues, and contributions are highly encouraged. Open a GitHub issue or start a pull request to help improve PHP concurrency and task management: @@ -237,6 +319,3 @@ Your feedback, issues, and contributions are highly encouraged. Open a GitHub is Multitron is MIT licensed. See the [LICENSE](LICENSE) file for full details. ---- - -**SEO Keywords:** PHP Task Orchestrator, Parallel Processing PHP, Symfony CLI Automation, Asynchronous PHP Tasks, Multitron PHP, PHP Concurrency, PHP Task Manager, Open-source PHP Library diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..3cef173 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,59 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 1.0.x | :white_check_mark: | +| < 1.0 | :x: | + +## Reporting a Vulnerability + +If you discover a security vulnerability in Multitron, please report it privately: + +**Email:** popelis@efabrica.sk + +**Please include:** +- Description of the vulnerability +- Steps to reproduce +- Potential impact +- Suggested fix (if any) + +**Response Time:** +- We aim to acknowledge reports within 48 hours +- We'll provide a more detailed response within 7 days +- Security patches will be released as soon as possible + +## Security Considerations + +### Process Execution +Multitron spawns worker processes using Inter-Process Communication (IPC). Ensure: +- Task factories don't execute untrusted code +- Command-line options are validated in your tasks +- Worker processes run with appropriate system permissions + +### Inter-Process Communication +- IPC data is serialized between processes +- Validate data received from shared cache +- Don't store sensitive data in the shared cache without encryption + +### Resource Limits +- Set appropriate memory limits using `-m` option +- Monitor worker process resource usage +- Implement timeouts for long-running tasks + +## Best Practices + +1. **Validate Input:** Always validate and sanitize data in your tasks +2. **Principle of Least Privilege:** Run workers with minimal required permissions +3. **Keep Updated:** Use the latest stable version +4. **Monitor Logs:** Review task output for suspicious activity +5. **Secure Dependencies:** Keep dependencies updated + +## Disclosure Policy + +- Security issues will be disclosed after a patch is available +- We'll credit researchers who report valid vulnerabilities (unless they prefer anonymity) +- CVE IDs will be requested for significant vulnerabilities + +Thank you for helping keep Multitron secure! 🔒 diff --git a/composer.json b/composer.json index 39424d4..4c24429 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,19 @@ "description": "Tool for managing fast both asynchronous and multi-threaded execution of tasks. Focused on performance and pleasant CLI interface.", "type": "library", "license": "MIT", + "keywords": [ + "php", + "task-orchestrator", + "parallel-processing", + "concurrency", + "async", + "task-runner", + "symfony-console", + "multi-threading", + "process-management", + "workflow", + "batch-processing" + ], "autoload": { "psr-4": { "Multitron\\": "src/" @@ -13,6 +26,11 @@ "Multitron\\Tests\\": "tests/" } }, + "homepage": "https://github.com/riki137/multitron", + "support": { + "issues": "https://github.com/riki137/multitron/issues", + "source": "https://github.com/riki137/multitron" + }, "repositories": [], "authors": [ { @@ -27,8 +45,6 @@ "psr/container": "^2.0", "psr/container-implementation": "*" }, - "minimum-stability": "beta", - "prefer-stable": true, "require-dev": { "tracy/tracy": "^2.10", "psr/log": "^3.0", @@ -36,7 +52,7 @@ "phpstan/phpstan": "^2.1", "contributte/psr11-container-interface": "^0.6.0", "phpunit/phpunit": "^11.5", - "squizlabs/php_codesniffer": "^3.13", + "squizlabs/php_codesniffer": "^3.13|^4.0", "symfony/dependency-injection": "^7.3", "illuminate/support": "^12", "illuminate/container": "^12", diff --git a/demo/demo-coffee.php b/demo/demo-coffee.php index 290dc49..41d5eb6 100644 --- a/demo/demo-coffee.php +++ b/demo/demo-coffee.php @@ -62,7 +62,7 @@ public function execute(TaskCommunicator $comm): void /** * Task 3 (partitioned): runs as multiple shards in parallel. - * - We simulate 1_000..5_000 tiny items. + * - We simulate 1,000..2,000 tiny items. * - Each shard reports progress. * - Occasionally we record an "occurrence" called SKIP to show counters. */ diff --git a/src/Bridge/Native/MultitronFactory.php b/src/Bridge/Native/MultitronFactory.php index 0e2329c..b27e4fb 100644 --- a/src/Bridge/Native/MultitronFactory.php +++ b/src/Bridge/Native/MultitronFactory.php @@ -121,7 +121,8 @@ public function getExecutionFactory(): ExecutionFactory { if ($this->executionFactory === null) { return $this->executionFactory = $this->getIpcAdapter()->createExecutionFactory( - $this->getProcessBufferSize(), $this->getWorkerTimeout() + $this->getProcessBufferSize(), + $this->getWorkerTimeout() ); } diff --git a/src/Bridge/Native/README.md b/src/Bridge/Native/README.md index c9226d3..daf7f38 100644 --- a/src/Bridge/Native/README.md +++ b/src/Bridge/Native/README.md @@ -37,8 +37,9 @@ use Symfony\Component\Console\Application; use Symfony\Component\Console\Attribute\AsCommand; // If you use a framework, you probably already have a container instance +// This is a minimal example - use a proper DI container in production (Pimple, PHP-DI, etc.) class AppContainer implements ContainerInterface { - public function get(string $id) + public function get(string $id): mixed { return new $id(); } diff --git a/src/Bridge/Nette/README.md b/src/Bridge/Nette/README.md index 650810e..9723270 100644 --- a/src/Bridge/Nette/README.md +++ b/src/Bridge/Nette/README.md @@ -5,7 +5,7 @@ First, require the necessary packages: ```bash -composer require riki137/mulitrtron contributte/psr11-container-interface +composer require riki137/multitron contributte/psr11-container-interface ``` ## Configuration diff --git a/src/Bridge/Symfony/README.md b/src/Bridge/Symfony/README.md index d635570..ab667e2 100644 --- a/src/Bridge/Symfony/README.md +++ b/src/Bridge/Symfony/README.md @@ -45,9 +45,11 @@ final class AppTaskCommand extends TaskCommand parent::__construct($deps); } - public function getNodes(TaskTreeBuilder $b): void + public function getNodes(TaskTreeBuilder $b): array { - // define your tasks here + return [ + // define your tasks here + ]; } } ``` diff --git a/src/Console/ProgressBar.php b/src/Console/ProgressBar.php index 46cb6a6..71731d7 100644 --- a/src/Console/ProgressBar.php +++ b/src/Console/ProgressBar.php @@ -27,7 +27,7 @@ public static function render(float $percent, int $width, string $barColor = 'gr $out = "" . str_repeat(self::CHARS[8], $fullBlocks) . - ($partialBlock > 0 ? self::CHARS[round($partialBlock * 8)] : '') . + ($partialBlock > 0 ? self::CHARS[(int)round($partialBlock * 8)] : '') . str_repeat(' ', $width - $fullBlocks - ($partialBlock > 0 ? 1 : 0)) . ''; if ($textColor !== null) { diff --git a/src/Console/TableRenderer.php b/src/Console/TableRenderer.php index f2399f7..34425b1 100644 --- a/src/Console/TableRenderer.php +++ b/src/Console/TableRenderer.php @@ -86,7 +86,7 @@ private static function getOccurrenceStatus(TaskState $state): string return implode(' ', $ret); } - private static function getProgressBar(TaskProgress $progress, float $percent = null, string $barColor = 'green'): string + private static function getProgressBar(TaskProgress $progress, ?float $percent = null, string $barColor = 'green'): string { $percent ??= $progress->getPercentage(); $textColor = 'white'; diff --git a/src/Execution/Process.php b/src/Execution/Process.php index d811c40..3e891b4 100644 --- a/src/Execution/Process.php +++ b/src/Execution/Process.php @@ -12,15 +12,15 @@ * • wait() – blocking; always returns the real exit code. * • close() – idempotent; closes pipes + reaps child; returns exit code. * - * @psalm-type Pipes = array{0:resource,1:resource,2:resource} + * @psalm-type Pipes = array<0|1|2, resource> */ -class Process +final class Process { /** @var resource */ private $process; - /** @var Pipes|never[] */ - private array $pipes = []; + /** @var Pipes */ + private readonly array $pipes; /** @var int<0,255>|null */ private ?int $exitCode = null; @@ -32,6 +32,7 @@ class Process */ public function __construct(array $cmd, ?string $cwd = null, ?array $env = null) { + $pipes = []; $proc = proc_open( $cmd, [ @@ -39,11 +40,12 @@ public function __construct(array $cmd, ?string $cwd = null, ?array $env = null) 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ], - $this->pipes, + $pipes, $cwd, $env, ['bypass_shell' => true] ); + $this->pipes = $pipes; if (!is_resource($proc)) { throw new RuntimeException('Failed to start process: ' . implode(' ', $cmd)); diff --git a/src/Orchestrator/Output/TableOutput.php b/src/Orchestrator/Output/TableOutput.php index b5a0fe7..93e9239 100644 --- a/src/Orchestrator/Output/TableOutput.php +++ b/src/Orchestrator/Output/TableOutput.php @@ -161,7 +161,6 @@ public function __destruct() /** * @param string[] $buffer - * @return void */ private function attachMemoryWarning(array &$buffer): void { diff --git a/tests/Integration/TableOutputIntegrationTest.php b/tests/Integration/TableOutputIntegrationTest.php index fece38c..b31b454 100644 --- a/tests/Integration/TableOutputIntegrationTest.php +++ b/tests/Integration/TableOutputIntegrationTest.php @@ -3,6 +3,7 @@ namespace Multitron\Tests\Integration; +use Multitron\Message\TaskWarningStateMessage; use Multitron\Orchestrator\Output\TableOutput; use Multitron\Orchestrator\Output\TableOutputFactory; use Multitron\Orchestrator\TaskList; @@ -12,6 +13,7 @@ use Multitron\Tree\TaskNode; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\ConsoleOutput; final class TableOutputIntegrationTest extends TestCase { @@ -57,5 +59,178 @@ public function testDestructorOutputsSummaryWhenNonInteractive(): void $out = $buffer->fetch(); $this->assertStringContainsString('TOTAL', $out); } + + public function testOnTaskUpdatedDoesNothing(): void + { + $list = $this->createTaskList(); + $buffer = new BufferedOutput(); + $table = new TableOutput($buffer, $list, false, TableOutputFactory::DEFAULT_LOW_MEMORY_WARNING); + + $state = new TaskState('task1'); + $table->onTaskStarted($state); + $table->onTaskUpdated($state); // Should not throw or do anything observable + $this->assertTrue(true); + } + + public function testDestructorInteractiveModeWithObBuffer(): void + { + $list = $this->createTaskList(); + $buffer = new BufferedOutput(); + $table = new TableOutput($buffer, $list, true, TableOutputFactory::DEFAULT_LOW_MEMORY_WARNING); + + $state = new TaskState('task1'); + $table->onTaskStarted($state); + ob_start(); + echo "buffered content"; + $state->setStatus(TaskStatus::SUCCESS); + $table->onTaskCompleted($state); + unset($table); + gc_collect_cycles(); + + $out = $buffer->fetch(); + $this->assertStringContainsString('buffered content', $out); + } + + public function testRenderInteractiveWithEmptyObBuffer(): void + { + $list = $this->createTaskList(); + $buffer = new BufferedOutput(); + $table = new TableOutput($buffer, $list, true, TableOutputFactory::DEFAULT_LOW_MEMORY_WARNING); + + $state = new TaskState('task1'); + $table->onTaskStarted($state); + + // Start output buffering to match what TableOutput expects + ob_start(); + $table->render(); + + // Should have started output buffering and cleared it + $out = $buffer->fetch(); + $this->assertNotEmpty($out); + } + + public function testRenderInteractiveWithWhitespaceObBuffer(): void + { + $list = $this->createTaskList(); + $buffer = new BufferedOutput(); + $table = new TableOutput($buffer, $list, true, TableOutputFactory::DEFAULT_LOW_MEMORY_WARNING); + + $state = new TaskState('task1'); + $table->onTaskStarted($state); + + // Start output buffering to match what TableOutput expects + ob_start(); + $table->render(); + + // Check that output contains task information + $out = $buffer->fetch(); + $this->assertStringContainsString('task1', $out); + } + + public function testConstructorWithConsoleOutput(): void + { + if (!class_exists(ConsoleOutput::class)) { + $this->markTestSkipped('ConsoleOutput not available'); + } + + $list = $this->createTaskList(); + $output = new ConsoleOutput(); + $table = new TableOutput($output, $list, true, TableOutputFactory::DEFAULT_LOW_MEMORY_WARNING); + + $state = new TaskState('task1'); + $table->onTaskStarted($state); + $state->setStatus(TaskStatus::SUCCESS); + $table->onTaskCompleted($state); + + // Start output buffering to allow destructor to clean it up properly + $obLevel = ob_get_level(); + ob_start(); + unset($table); + gc_collect_cycles(); + // Clean up any buffers created during the test to restore original state + while (ob_get_level() > $obLevel) { + ob_end_clean(); + } + + $this->assertTrue(true); // If we get here without errors, the test passes + } + + public function testTaskWithWarnings(): void + { + $list = $this->createTaskList(); + $buffer = new BufferedOutput(); + $table = new TableOutput($buffer, $list, false, TableOutputFactory::DEFAULT_LOW_MEMORY_WARNING); + + $state = new TaskState('task1'); + $table->onTaskStarted($state); + + // Add a warning to the state + $state->getWarnings()->add('Test warning', 1); + + $state->setStatus(TaskStatus::SUCCESS); + $table->onTaskCompleted($state); + unset($table); + gc_collect_cycles(); + + $out = $buffer->fetch(); + $this->assertStringContainsString('Test warning', $out); + } + + public function testLowMemoryWarning(): void + { + $list = $this->createTaskList(); + $buffer = new BufferedOutput(); + // Set a very high low memory warning threshold to trigger the warning + $table = new TableOutput($buffer, $list, false, 999999); + + $state = new TaskState('task1'); + $table->onTaskStarted($state); + $state->setStatus(TaskStatus::SUCCESS); + $table->onTaskCompleted($state); + unset($table); + gc_collect_cycles(); + + $out = $buffer->fetch(); + // Should contain low memory warning if /proc/meminfo is available + $this->assertNotEmpty($out); + } + + public function testBuildSectionBufferWithMemoryUsage(): void + { + $list = $this->createTaskList(); + $buffer = new BufferedOutput(); + $table = new TableOutput($buffer, $list, true, TableOutputFactory::DEFAULT_LOW_MEMORY_WARNING); + + $state = new TaskState('task1'); + // Set memory usage on the state's progress + $state->getProgress()->memoryUsage = 1048576; // 1MB + + $table->onTaskStarted($state); + + // Start output buffering to match what TableOutput expects + ob_start(); + $table->render(); + + $out = $buffer->fetch(); + $this->assertNotEmpty($out); + } + + public function testAttachMemoryWarningWithZeroThreshold(): void + { + $list = $this->createTaskList(); + $buffer = new BufferedOutput(); + // Zero threshold should skip memory warning + $table = new TableOutput($buffer, $list, false, 0); + + $state = new TaskState('task1'); + $table->onTaskStarted($state); + $state->setStatus(TaskStatus::SUCCESS); + $table->onTaskCompleted($state); + unset($table); + gc_collect_cycles(); + + // Should complete without low memory warning + $this->assertTrue(true); + } } diff --git a/tests/Integration/TaskOrchestratorIntegrationTest.php b/tests/Integration/TaskOrchestratorIntegrationTest.php index 287e4c4..39672af 100644 --- a/tests/Integration/TaskOrchestratorIntegrationTest.php +++ b/tests/Integration/TaskOrchestratorIntegrationTest.php @@ -74,5 +74,33 @@ public function testLogsFailureAndExitCode(): void $out = $output->fetch(); $this->assertStringContainsString('Worker exited with code 1', $out); } -} + public function testFailureSkipsDependencies(): void + { + $peer = new NativeIpcPeer(); + $execFactory = new DummyExecutionFactory($peer, 1, ['exitCode' => 1, 'stdout' => '', 'stderr' => '']); + $registryFactory = new DummyIpcHandlerRegistryFactory(); + $tableFactory = new TableOutputFactory(); + $orchestrator = new TaskOrchestrator($peer, $execFactory, $tableFactory, $registryFactory); + + $builder = new TaskTreeBuilder(new AppContainer()); + $a = $builder->task('A', fn() => new DummyTask()); + $b = $builder->task('B', fn() => new DummyTask(), [$a]); + $c = $builder->task('C', fn() => new DummyTask(), [$b]); + $root = $builder->group('root', [$a, $b, $c]); + $list = new TaskList($root); + $queue = new TaskTreeQueue($list, 1); + + $inputDef = new InputDefinition([new InputOption(TaskOrchestrator::OPTION_UPDATE_INTERVAL, 'u', InputOption::VALUE_REQUIRED)]); + $input = new ArrayInput(['--' . TaskOrchestrator::OPTION_UPDATE_INTERVAL => '0.01'], $inputDef); + $output = new BufferedOutput(); + $registry = $registryFactory->create(); + $progress = $tableFactory->create($list, $output, $registry, ['interactive' => false]); + + $result = $orchestrator->doRun('demo', $input->getOptions(), $queue, $progress, $registry); + + $this->assertSame(1, $result); + $out = $output->fetch(); + $this->assertStringContainsString('⚠', $out); + } +} diff --git a/tests/Mocks/DummyOutput.php b/tests/Mocks/DummyOutput.php index 88737b6..b2d01f1 100644 --- a/tests/Mocks/DummyOutput.php +++ b/tests/Mocks/DummyOutput.php @@ -9,6 +9,7 @@ class DummyOutput implements ProgressOutput { public array $completed = []; + public array $logs = []; public function onTaskStarted(TaskState $state): void {} @@ -19,7 +20,10 @@ public function onTaskCompleted(TaskState $state): void $this->completed[] = $state; } - public function log(TaskState $state, string $message): void {} + public function log(TaskState $state, string $message): void + { + $this->logs[] = [$state, $message]; + } public function render(): void {} } diff --git a/tests/Unit/ChainProgressOutputFactoryTest.php b/tests/Unit/ChainProgressOutputFactoryTest.php new file mode 100644 index 0000000..e585e51 --- /dev/null +++ b/tests/Unit/ChainProgressOutputFactoryTest.php @@ -0,0 +1,57 @@ +create($taskList, $output, $registry, []); + + $this->assertInstanceOf(\Multitron\Orchestrator\Output\ChainProgressOutput::class, $result); + + // Test that it forwards to both outputs + $state = new TaskState('t1'); + $result->onTaskStarted($state); + // If this doesn't throw, the chain was created correctly + $this->assertTrue(true); + } + + public function testConstructorAcceptsVariadicFactories(): void + { + $factory = new ChainProgressOutputFactory( + new SimpleFactory(), + new SimpleFactory(), + new SimpleFactory() + ); + + $this->assertInstanceOf(ChainProgressOutputFactory::class, $factory); + } +} diff --git a/tests/Unit/TaskOrchestratorTest.php b/tests/Unit/TaskOrchestratorTest.php index e680af3..d8c1651 100644 --- a/tests/Unit/TaskOrchestratorTest.php +++ b/tests/Unit/TaskOrchestratorTest.php @@ -8,6 +8,7 @@ use Multitron\Tests\Mocks\DummyExecution; use Multitron\Tests\Mocks\DummyOutput; use Multitron\Tests\Mocks\OrchestratorPeer; +use Multitron\Execution\Handler\DefaultIpcHandlerRegistryFactory; use Multitron\Execution\Handler\IpcHandlerRegistryFactory; use Multitron\Orchestrator\TaskList; use Multitron\Orchestrator\TaskOrchestrator; @@ -61,5 +62,71 @@ public function testIgnoresWhenNoStateMatches(): void $this->orch->handleStreamException(new InvalidStreamException($other), ['t1' => $state], $this->queue, $this->output); $this->assertCount(0, $this->output->completed); } + + public function testOnErrorWithNullExecution(): void + { + $state = new TaskState('t1', null); + $this->orch->onError($state, $this->queue, $this->output); + + $this->assertSame(TaskStatus::ERROR, $state->getStatus()); + $this->assertCount(1, $this->output->completed); + $this->assertCount(1, $this->output->logs); + $this->assertStringContainsString('No execution found', $this->output->logs[0][1]); + } + + public function testOnErrorWithExecution(): void + { + $exec = new DummyExecution($this->peer->session); + $state = new TaskState('t1', $exec); + + // Add a task to the queue so we can test skipping + $taskList = new TaskList(new TaskNode('root', null, [ + new TaskNode('t1', fn() => new \Multitron\Tests\Mocks\DummyTask()), + new TaskNode('t2', fn() => new \Multitron\Tests\Mocks\DummyTask(), dependencies: ['t1']), + ])); + $queue = new TaskTreeQueue($taskList); + + $this->orch->onError($state, $queue, $this->output); + + $this->assertSame(TaskStatus::ERROR, $state->getStatus()); + $this->assertGreaterThanOrEqual(1, $this->output->completed); + $this->assertCount(1, $this->output->logs); + $this->assertStringContainsString('Worker exited with code', $this->output->logs[0][1]); + } + + public function testDoRunWithInvalidUpdateInterval(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Update interval must be a number'); + + $registry = new \Multitron\Execution\Handler\IpcHandlerRegistry(); + + $this->orch->doRun( + 'test', + ['update-interval' => 'invalid'], + $this->queue, + $this->output, + $registry + ); + } + + public function testOnErrorWithSkippedDependencies(): void + { + $exec = new DummyExecution($this->peer->session); + $state = new TaskState('t1', $exec); + + // Create a more complex dependency tree + $taskList = new TaskList(new TaskNode('root', null, [ + new TaskNode('t1', fn() => new \Multitron\Tests\Mocks\DummyTask()), + new TaskNode('t2', fn() => new \Multitron\Tests\Mocks\DummyTask(), dependencies: ['t1']), + new TaskNode('t3', fn() => new \Multitron\Tests\Mocks\DummyTask(), dependencies: ['t2']), + ])); + $queue = new TaskTreeQueue($taskList); + + $this->orch->onError($state, $queue, $this->output); + + // Should have skipped t2 and t3 + $this->assertGreaterThanOrEqual(2, $this->output->completed); + } } diff --git a/tests/Unit/TaskTreeBuilderTest.php b/tests/Unit/TaskTreeBuilderTest.php index 7eb888b..4c26d60 100644 --- a/tests/Unit/TaskTreeBuilderTest.php +++ b/tests/Unit/TaskTreeBuilderTest.php @@ -65,5 +65,123 @@ public function testPartitionedFactoryThrowsWithoutContainer(): void $this->expectExceptionMessage('TaskTreeBuilderFactory has no container injected'); ($node->children[0]->factory)(); } + + public function testPartitionedClosureWithNonPartitionedTaskThrows(): void + { + $builder = new TaskTreeBuilder(null); + $node = $builder->partitionedClosure( + 'test', + fn() => new DummyTask(), // Returns Task, not PartitionedTaskInterface + 2 + ); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Expected PartitionedTaskInterface, got'); + ($node->children[0]->factory)(); + } + + public function testPartitionedClosureSuccess(): void + { + $builder = new TaskTreeBuilder(null); + $node = $builder->partitionedClosure( + 'test', + fn() => new DummyPartitionTask(), + 3, + ['dep1'] + ); + + $this->assertCount(3, $node->children); + $this->assertSame('test', $node->id); + $this->assertSame(['dep1'], $node->dependencies); + + // Test first partition + $task = ($node->children[0]->factory)(); + $this->assertInstanceOf(DummyPartitionTask::class, $task); + $this->assertSame('test 1/3', $node->children[0]->id); + + // Test second partition + $task2 = ($node->children[1]->factory)(); + $this->assertInstanceOf(DummyPartitionTask::class, $task2); + $this->assertSame('test 2/3', $node->children[1]->id); + } + + public function testPartitionedWithContainer(): void + { + $container = new Container(new \Pimple\Container([ + DummyPartitionTask::class => fn() => new DummyPartitionTask(), + ])); + $builder = new TaskTreeBuilder($container); + $node = $builder->partitioned(DummyPartitionTask::class, 2, ['dep'], 'custom-id'); + + $this->assertSame('custom-id', $node->id); + $this->assertCount(2, $node->children); + $this->assertSame(['dep'], $node->dependencies); + + $task = ($node->children[0]->factory)(); + $this->assertInstanceOf(DummyPartitionTask::class, $task); + } + + public function testServiceWithCustomId(): void + { + $container = new Container(new \Pimple\Container([ + DummyTask::class => fn() => new DummyTask(), + ])); + $builder = new TaskTreeBuilder($container); + $node = $builder->service(DummyTask::class, ['dep1', 'dep2'], 'my-custom-id'); + + $this->assertSame('my-custom-id', $node->id); + $this->assertSame(['dep1', 'dep2'], $node->dependencies); + } + + public function testTaskMethod(): void + { + $builder = new TaskTreeBuilder(null); + $node = $builder->task('test-task', fn() => new DummyTask(), ['dep1']); + + $this->assertSame('test-task', $node->id); + $this->assertSame(['dep1'], $node->dependencies); + $this->assertSame([], $node->tags); + + $task = ($node->factory)(); + $this->assertInstanceOf(DummyTask::class, $task); + } + + public function testGroupMethod(): void + { + $builder = new TaskTreeBuilder(null); + $child1 = $builder->task('child1', fn() => new DummyTask()); + $child2 = $builder->task('child2', fn() => new DummyTask()); + + $group = $builder->group('my-group', [$child1, $child2], ['dep1']); + + $this->assertSame('my-group', $group->id); + $this->assertSame(['dep1'], $group->dependencies); + $this->assertCount(2, $group->children); + $this->assertNull($group->factory); + } + + public function testPatternFilter(): void + { + $builder = new TaskTreeBuilder(null); + $child1 = $builder->task('child1', fn() => new DummyTask()); + + $node = $builder->patternFilter('pattern-id', 'some-pattern', [$child1]); + + $this->assertSame('pattern-id', $node->id); + $this->assertCount(1, $node->children); + } + + public function testGetPartitionedTaskNotImplementingInterface(): void + { + $container = new Container(new \Pimple\Container([ + DummyTask::class => fn() => new DummyTask(), + ])); + $builder = new TaskTreeBuilder($container); + $node = $builder->partitioned(DummyTask::class, 1); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('must implement PartitionedTaskInterface'); + ($node->children[0]->factory)(); + } } diff --git a/tests/Unit/TaskTreeCompilerTest.php b/tests/Unit/TaskTreeCompilerTest.php index 7f37a33..96f496a 100644 --- a/tests/Unit/TaskTreeCompilerTest.php +++ b/tests/Unit/TaskTreeCompilerTest.php @@ -21,5 +21,158 @@ public function testTagDependencyExpansion(): void $this->assertArrayHasKey('second', $compiled); $this->assertSame(['first'], $compiled['second']->dependencies); } + + public function testBuildTagIndexWithParents(): void + { + // Test that parent IDs are included in tag index for children + $child = new TaskNode('child', fn() => new DummyTask()); + $parent = new TaskNode('parent', null, [$child]); + $root = new TaskNode('root', null, [$parent]); + + $compiled = (new TaskTreeCompiler())->compile($root); + + // Child should depend on all parent IDs that are in the tag index + $this->assertArrayHasKey('child', $compiled); + } + + public function testNestedTagExpansion(): void + { + $t1 = new TaskNode('task1', fn() => new DummyTask(), tags: ['db']); + $t2 = new TaskNode('task2', fn() => new DummyTask(), tags: ['db']); + $t3 = new TaskNode('task3', fn() => new DummyTask(), dependencies: ['db']); + $root = new TaskNode('root', null, [$t1, $t2, $t3]); + + $compiled = (new TaskTreeCompiler())->compile($root); + + $this->assertArrayHasKey('task3', $compiled); + $this->assertCount(2, $compiled['task3']->dependencies); + $this->assertContains('task1', $compiled['task3']->dependencies); + $this->assertContains('task2', $compiled['task3']->dependencies); + } + + public function testParentDependenciesInheritedByChildren(): void + { + $dep = new TaskNode('dependency', fn() => new DummyTask()); + $child = new TaskNode('child', fn() => new DummyTask()); + $parent = new TaskNode('parent', null, [$child], dependencies: ['dependency']); + $root = new TaskNode('root', null, [$dep, $parent]); + + $compiled = (new TaskTreeCompiler())->compile($root); + + $this->assertArrayHasKey('child', $compiled); + $this->assertContains('dependency', $compiled['child']->dependencies); + } + + public function testPostProcessTransformsSubtree(): void + { + $t1 = new TaskNode('t1', fn() => new DummyTask()); + $parent = new TaskNode('parent', null, [$t1], postProcess: function (array $tasks) { + // Transform tasks by adding a prefix + $result = []; + foreach ($tasks as $task) { + $result['modified-' . $task->id] = new \Multitron\Tree\CompiledTaskNode( + 'modified-' . $task->id, + $task->factory, + $task->dependencies, + $task->tags + ); + } + return $result; + }); + $root = new TaskNode('root', null, [$parent]); + + $compiled = (new TaskTreeCompiler())->compile($root); + + $this->assertArrayHasKey('modified-t1', $compiled); + $this->assertArrayNotHasKey('t1', $compiled); + } + + public function testPostProcessInvalidReturnTypeThrows(): void + { + $t1 = new TaskNode('t1', fn() => new DummyTask()); + $parent = new TaskNode('parent', null, [$t1], postProcess: function (array $tasks) { + yield 'not-a-compiled-task'; + }); + $root = new TaskNode('root', null, [$parent]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Post-processing closure must return an iterable of CompiledTaskNode objects'); + (new TaskTreeCompiler())->compile($root); + } + + public function testTagsInheritedByChildren(): void + { + $child = new TaskNode('child', fn() => new DummyTask()); + $parent = new TaskNode('parent', null, [$child], tags: ['parent-tag']); + $root = new TaskNode('root', null, [$parent]); + + $compiled = (new TaskTreeCompiler())->compile($root); + + $this->assertArrayHasKey('child', $compiled); + $this->assertContains('parent-tag', $compiled['child']->tags); + } + + public function testNodeWithoutFactory(): void + { + // A node without a factory is just a grouping node + $child = new TaskNode('child', fn() => new DummyTask()); + $parent = new TaskNode('parent', null, [$child]); + $root = new TaskNode('root', null, [$parent]); + + $compiled = (new TaskTreeCompiler())->compile($root); + + // Only the child should be in the compiled list + $this->assertArrayNotHasKey('parent', $compiled); + $this->assertArrayHasKey('child', $compiled); + } + + public function testCircularDependencyViaTagExpansion(): void + { + // A task depending on a tag that includes itself should not cause infinite loop + $t1 = new TaskNode('t1', fn() => new DummyTask(), tags: ['group'], dependencies: ['group']); + $root = new TaskNode('root', null, [$t1]); + + $compiled = (new TaskTreeCompiler())->compile($root); + + $this->assertArrayHasKey('t1', $compiled); + // The dependency resolution should handle this - it may include t1 or not, depending on tag index order + // The important part is it doesn't infinite loop + $this->assertIsArray($compiled['t1']->dependencies); + } + + public function testGetDirectDependenciesWithTaskNode(): void + { + $dep = new TaskNode('dep', fn() => new DummyTask()); + $t1 = new TaskNode('t1', fn() => new DummyTask(), dependencies: [$dep]); + $root = new TaskNode('root', null, [$dep, $t1]); + + $compiled = (new TaskTreeCompiler())->compile($root); + + $this->assertArrayHasKey('t1', $compiled); + $this->assertContains('dep', $compiled['t1']->dependencies); + } + + public function testGetDirectDependenciesInvalidType(): void + { + $t1 = new TaskNode('t1', fn() => new DummyTask(), dependencies: [123]); + $root = new TaskNode('root', null, [$t1]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Dependencies must be either TaskNode instances or strings'); + (new TaskTreeCompiler())->compile($root); + } + + public function testDuplicateDependencies(): void + { + $t1 = new TaskNode('dep', fn() => new DummyTask()); + $t2 = new TaskNode('task', fn() => new DummyTask(), dependencies: ['dep', 'dep']); // Duplicate dependency + $root = new TaskNode('root', null, [$t1, $t2]); + + $compiled = (new TaskTreeCompiler())->compile($root); + + $this->assertArrayHasKey('task', $compiled); + // Should only contain 'dep' once even though it was listed twice + $this->assertSame(['dep'], $compiled['task']->dependencies); + } }